diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index 0422128daff3..d2f1fd723b1a 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -11,15 +11,21 @@ inputs: runs: using: 'composite' steps: + # Resolve the app's @playwright/test version. Some apps (e.g. the node bundler apps) assert via a + # plain node script and don't depend on Playwright — emit an empty version instead of failing, so + # the steps below skip the (wasted) browser install rather than erroring on the missing package. - name: Get Playwright version id: playwright-version - run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT + run: | + version=$(node -e "let v = ''; try { v = require('@playwright/test/package.json').version; } catch {} process.stdout.write(String(v));") + echo "version=$version" >> "$GITHUB_OUTPUT" shell: bash working-directory: ${{ inputs.cwd }} - name: Restore cached playwright binaries uses: actions/cache/restore@v6.1.0 id: playwright-cache + if: steps.playwright-version.outputs.version != '' with: path: | ~/.cache/ms-playwright @@ -29,7 +35,7 @@ runs: # We always install all browsers, if uncached - name: Install Playwright dependencies (uncached) run: npx playwright install chromium webkit firefox --with-deps - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true' shell: bash working-directory: ${{ inputs.cwd }} @@ -37,14 +43,16 @@ runs: env: PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }} run: npx playwright install-deps "$PLAYWRIGHT_BROWSERS" - if: steps.playwright-cache.outputs.cache-hit == 'true' + if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true' shell: bash working-directory: ${{ inputs.cwd }} # Only store cache on develop branch - name: Store cached playwright binaries uses: actions/cache/save@v6.1.0 - if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + if: + steps.playwright-version.outputs.version != '' && github.event_name == 'push' && github.ref == + 'refs/heads/develop' with: path: | ~/.cache/ms-playwright diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs b/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs index 7d0d77216931..cb58bfa5c839 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs @@ -1,61 +1,7 @@ -/** - * Asserts that `sentryEsbuildPlugin` performs build-time instrumentation: its code transform injects - * the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not. - * - * @module - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform -// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`). -// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build -// apart from a `plain` one. Before matching we strip block comments and whitespace, because bundlers -// format the injected banner differently — Rolldown pretty-prints it and inserts a `/* @__PURE__ */` -// annotation. The banner initializes the set with `new Set()`, hence the stripped `newSet()` form. -const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||newSet()'; - -function bundleText(name) { - const files = []; - const walk = dir => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else { - files.push(full); - } - } - }; - walk(join(__dirname, 'dist', name)); - return files - .map(f => readFileSync(f, 'utf8')) - .join('\n') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\s+/g, ''); -} - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); - -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); -check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryEsbuildPlugin runs build-time instrumentation (injects the orchestrion banner)', -); - -if (failed) { - process.exit(1); -} -// eslint-disable-next-line no-console -console.log('All bundle assertions passed.'); +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each +// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation` +// in `@sentry-internal/test-utils` for the full matrix. +assertBundlerInstrumentation('graphql'); diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs b/dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs index 731ba2bf709d..99d569f8528f 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs @@ -1,9 +1,13 @@ -// Bundles the entrypoint with esbuild twice: -// - `plain`: no Sentry plugin. -// - `plugin`: with `sentryEsbuildPlugin` (build-time instrumentation). -// Only the `plugin` build runs the orchestrion code transform, which prepends the "bundler ran" -// banner to the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would -// rename them); assert.mjs matches it whitespace-insensitively. +// Bundles the entrypoint with esbuild four ways, each a directly-runnable CJS bundle: +// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryEsbuildPlugin`) +// build-time instruments it. Run without `--import`. +// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can +// intercept it at load time. Run with `--import`. +// esbuild emits CJS (not ESM): its ESM output can't perform the CJS `require('node:async_hooks')` that +// `@sentry/server-utils` does once inlined, and CJS is the normal esbuild node target. `assert.mjs` +// runs all four and checks the query works and that exactly one set of graphql spans is emitted in +// each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers. +import { rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; @@ -11,31 +15,36 @@ import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; const __dirname = dirname(fileURLToPath(import.meta.url)); -function run(name, plugins) { +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + sentryEsbuildPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +function run(name, { external, plugins }) { return build({ entryPoints: [join(__dirname, 'src', 'entry.mjs')], - outdir: join(__dirname, 'dist', name), + outfile: join(__dirname, 'dist', name, 'main.cjs'), bundle: true, platform: 'node', - format: 'esm', + format: 'cjs', + // The `*-external` variants keep graphql out of the bundle, so it is resolved from node_modules at + // runtime and the `--import` hook can transform it as it loads. + external: external ? ['graphql'] : [], minify: false, logLevel: 'silent', plugins, }); } -await run('plain', []); -await run( - 'plugin', - // No auth/release/telemetry — we only care about the build-time transforms and defines. - [ - sentryEsbuildPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), - ], -); +await run('plain', { external: false, plugins: [] }); +await run('plugin', { external: false, plugins: [makeSentryPlugin()] }); +await run('plain-external', { external: true, plugins: [] }); +await run('plugin-external', { external: true, plugins: [makeSentryPlugin()] }); // eslint-disable-next-line no-console -console.log('built plain + plugin with esbuild'); +console.log('built plain + plugin (inlined) and plain-external + plugin-external with esbuild'); diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/package.json b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json index e9e33f245570..9067ae46f6a6 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/package.json +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json @@ -1,6 +1,6 @@ { "name": "node-esbuild", - "description": "ensure the Sentry esbuild plugin performs build-time instrumentation", + "description": "ensure the Sentry esbuild plugin build-time instruments a bundled graphql at runtime", "version": "1.0.0", "private": true, "type": "module", @@ -15,6 +15,8 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "graphql": "16.9.0", "esbuild": "0.28.2" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-esbuild/src/app.mjs index e66db6685328..40666ff77ead 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion +// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range +// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay +// library-agnostic. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs index 5c03b545d672..c59d82e4ff24 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs @@ -1,9 +1,48 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written +// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return +// value rides along as `result` so the assertion can check it without knowing what the workload does. +// +// The body is an async function rather than top-level await so the same source bundles to both ESM +// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await). +import { writeFileSync } from 'node:fs'; import * as Sentry from '@sentry/node'; -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, -}); +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); -await import('./app.mjs'); + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + // Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit + // can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs index 28cd7004f32c..cb58bfa5c839 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs @@ -1,61 +1,7 @@ -/** - * Asserts that `sentryRollupPlugin` performs build-time instrumentation when bundling with Rolldown: its code transform injects - * the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not. - * - * @module - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform -// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`). -// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build -// apart from a `plain` one. Before matching we strip block comments and whitespace, because bundlers -// format the injected banner differently — Rolldown pretty-prints it and inserts a `/* @__PURE__ */` -// annotation. The banner initializes the set with `new Set()`, hence the stripped `newSet()` form. -const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||newSet()'; - -function bundleText(name) { - const files = []; - const walk = dir => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else { - files.push(full); - } - } - }; - walk(join(__dirname, 'dist', name)); - return files - .map(f => readFileSync(f, 'utf8')) - .join('\n') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\s+/g, ''); -} - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); - -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); -check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)', -); - -if (failed) { - process.exit(1); -} -// eslint-disable-next-line no-console -console.log('All bundle assertions passed.'); +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each +// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation` +// in `@sentry-internal/test-utils` for the full matrix. +assertBundlerInstrumentation('graphql'); diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs b/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs index 8cbc1581bae7..2f0b067beeba 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs @@ -1,11 +1,13 @@ -// Bundles the entrypoint with Rolldown twice: -// - `plain`: no Sentry plugin. -// - `plugin`: with `sentryRollupPlugin` (build-time instrumentation). -// Only the `plugin` build runs the orchestrion code transform, which prepends the "bundler ran" -// banner to the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would -// rename them); assert.mjs matches it whitespace-insensitively. +// Bundles the entrypoint with Rolldown four ways, each a directly-runnable ESM bundle: +// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryRollupPlugin`) +// build-time instruments it. Run without `--import`. +// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can +// intercept it at load time. Run with `--import`. // Rolldown is Rollup API-compatible, so it consumes the same `@sentry/node/rollup` plugin; it also // resolves node modules and CommonJS natively, so no extra resolve/commonjs plugins are needed. +// `assert.mjs` runs all four and checks the query works and that exactly one set of graphql spans is +// emitted in each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers. +import { rmSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,30 +15,37 @@ import { rolldown } from 'rolldown'; import { sentryRollupPlugin } from '@sentry/node/rollup'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; +const nodeExternals = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; -async function run(name, extra) { +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + // `sentryRollupPlugin` returns an array of Rollup plugins. + sentryRollupPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +async function run(name, { external, plugins }) { const bundle = await rolldown({ input: join(__dirname, 'src', 'entry.mjs'), - external, - plugins: [...extra], + // The `*-external` variants keep graphql out of the bundle, so it is resolved from node_modules at + // runtime and the `--import` hook can transform it as it loads. + external: external ? [...nodeExternals, 'graphql'] : nodeExternals, + plugins: [...plugins], + platform: 'node', onwarn: () => {}, }); await bundle.write({ dir: join(__dirname, 'dist', name), format: 'es', entryFileNames: 'main.mjs' }); await bundle.close(); } -await run('plain', []); -await run( - 'plugin', - // `sentryRollupPlugin` returns an array of Rollup plugins. No auth/release/telemetry — we only care - // about the build-time transforms and defines. - sentryRollupPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), -); +await run('plain', { external: false, plugins: [] }); +await run('plugin', { external: false, plugins: makeSentryPlugin() }); +await run('plain-external', { external: true, plugins: [] }); +await run('plugin-external', { external: true, plugins: makeSentryPlugin() }); // eslint-disable-next-line no-console -console.log('built plain + plugin with rolldown'); +console.log('built plain + plugin (inlined) and plain-external + plugin-external with rolldown'); diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/package.json b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json index ea32d98dc0bf..c0edf0628f9e 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json @@ -1,6 +1,6 @@ { "name": "node-rolldown", - "description": "ensure the Sentry rollup plugin performs build-time instrumentation when bundling with rolldown", + "description": "ensure the Sentry rollup plugin build-time instruments a bundled graphql when bundling with rolldown at runtime", "version": "1.0.0", "private": true, "type": "module", @@ -15,6 +15,8 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "graphql": "16.9.0", "rolldown": "1.2.5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-rolldown/src/app.mjs index e66db6685328..40666ff77ead 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion +// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range +// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay +// library-agnostic. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs index 5c03b545d672..c59d82e4ff24 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs @@ -1,9 +1,48 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written +// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return +// value rides along as `result` so the assertion can check it without knowing what the workload does. +// +// The body is an async function rather than top-level await so the same source bundles to both ESM +// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await). +import { writeFileSync } from 'node:fs'; import * as Sentry from '@sentry/node'; -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, -}); +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); -await import('./app.mjs'); + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + // Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit + // can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs index 69f1f0f42a68..cb58bfa5c839 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs @@ -1,61 +1,7 @@ -/** - * Asserts that `sentryRollupPlugin` performs build-time instrumentation: its code transform injects - * the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not. - * - * @module - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform -// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`). -// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build -// apart from a `plain` one. Before matching we strip block comments and whitespace, because bundlers -// format the injected banner differently — Rolldown pretty-prints it and inserts a `/* @__PURE__ */` -// annotation. The banner initializes the set with `new Set()`, hence the stripped `newSet()` form. -const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||newSet()'; - -function bundleText(name) { - const files = []; - const walk = dir => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else { - files.push(full); - } - } - }; - walk(join(__dirname, 'dist', name)); - return files - .map(f => readFileSync(f, 'utf8')) - .join('\n') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\s+/g, ''); -} - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); - -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); -check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)', -); - -if (failed) { - process.exit(1); -} -// eslint-disable-next-line no-console -console.log('All bundle assertions passed.'); +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each +// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation` +// in `@sentry-internal/test-utils` for the full matrix. +assertBundlerInstrumentation('graphql'); diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/build.mjs b/dev-packages/e2e-tests/test-applications/node-rollup/build.mjs index ca9695752bc8..8a9c352bff07 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/build.mjs @@ -1,9 +1,11 @@ -// Bundles the entrypoint with Rollup twice: -// - `plain`: no Sentry plugin. -// - `plugin`: with `sentryRollupPlugin` (build-time instrumentation). -// Only the `plugin` build runs the orchestrion code transform, which prepends the "bundler ran" -// banner to the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would -// rename them); assert.mjs matches it whitespace-insensitively. +// Bundles the entrypoint with Rollup four ways, each a directly-runnable ESM bundle: +// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryRollupPlugin`) +// build-time instruments it. Run without `--import`. +// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can +// intercept it at load time. Run with `--import`. +// `assert.mjs` runs all four and checks the query works and that exactly one set of graphql spans is +// emitted in each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers. +import { rmSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,30 +15,36 @@ import { rollup } from 'rollup'; import { sentryRollupPlugin } from '@sentry/node/rollup'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; +const nodeExternals = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; -async function run(name, extra) { +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + // `sentryRollupPlugin` returns an array of Rollup plugins. + sentryRollupPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +async function run(name, { external, plugins }) { const bundle = await rollup({ input: join(__dirname, 'src', 'entry.mjs'), - external, - plugins: [nodeResolve({ exportConditions: ['node', 'import', 'default'] }), commonjs(), ...extra], + // The `*-external` variants keep graphql out of the bundle, so it is resolved from node_modules at + // runtime and the `--import` hook can transform it as it loads. + external: external ? [...nodeExternals, 'graphql'] : nodeExternals, + plugins: [nodeResolve({ exportConditions: ['node', 'import', 'default'] }), commonjs(), ...plugins], onwarn: () => {}, }); await bundle.write({ dir: join(__dirname, 'dist', name), format: 'es', entryFileNames: 'main.mjs' }); await bundle.close(); } -await run('plain', []); -await run( - 'plugin', - // `sentryRollupPlugin` returns an array of Rollup plugins. No auth/release/telemetry — we only care - // about the build-time transforms and defines. - sentryRollupPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), -); +await run('plain', { external: false, plugins: [] }); +await run('plugin', { external: false, plugins: makeSentryPlugin() }); +await run('plain-external', { external: true, plugins: [] }); +await run('plugin-external', { external: true, plugins: makeSentryPlugin() }); // eslint-disable-next-line no-console -console.log('built plain + plugin with rollup'); +console.log('built plain + plugin (inlined) and plain-external + plugin-external with rollup'); diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/package.json b/dev-packages/e2e-tests/test-applications/node-rollup/package.json index 787d4d9af92e..40cc0d2cdada 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rollup/package.json @@ -1,6 +1,6 @@ { "name": "node-rollup", - "description": "ensure the Sentry rollup plugin performs build-time instrumentation", + "description": "ensure the Sentry rollup plugin build-time instruments a bundled graphql at runtime", "version": "1.0.0", "private": true, "type": "module", @@ -15,6 +15,8 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "graphql": "16.9.0", "rollup": "4.62.3", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-commonjs": "^28.0.0" diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-rollup/src/app.mjs index e66db6685328..40666ff77ead 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion +// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range +// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay +// library-agnostic. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-rollup/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-rollup/src/entry.mjs index 5c03b545d672..c59d82e4ff24 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/src/entry.mjs @@ -1,9 +1,48 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written +// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return +// value rides along as `result` so the assertion can check it without knowing what the workload does. +// +// The body is an async function rather than top-level await so the same source bundles to both ESM +// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await). +import { writeFileSync } from 'node:fs'; import * as Sentry from '@sentry/node'; -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, -}); +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); -await import('./app.mjs'); + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + // Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit + // can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs index 654faa17c083..cb58bfa5c839 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs @@ -1,61 +1,7 @@ -/** - * Asserts that `sentryVitePlugin` performs build-time instrumentation: its code transform injects - * the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not. - * - * @module - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform -// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`). -// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build -// apart from a `plain` one. Before matching we strip block comments and whitespace, because bundlers -// format the injected banner differently — Rolldown pretty-prints it and inserts a `/* @__PURE__ */` -// annotation. The banner initializes the set with `new Set()`, hence the stripped `newSet()` form. -const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||newSet()'; - -function bundleText(name) { - const files = []; - const walk = dir => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else { - files.push(full); - } - } - }; - walk(join(__dirname, 'dist', name)); - return files - .map(f => readFileSync(f, 'utf8')) - .join('\n') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\s+/g, ''); -} - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); - -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); -check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryVitePlugin runs build-time instrumentation (injects the orchestrion banner)', -); - -if (failed) { - process.exit(1); -} -// eslint-disable-next-line no-console -console.log('All bundle assertions passed.'); +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each +// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation` +// in `@sentry-internal/test-utils` for the full matrix. +assertBundlerInstrumentation('graphql'); diff --git a/dev-packages/e2e-tests/test-applications/node-vite/build.mjs b/dev-packages/e2e-tests/test-applications/node-vite/build.mjs index 2b62653cd64b..6b3e5e267905 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/build.mjs @@ -1,11 +1,13 @@ -// Bundles the entrypoint with Vite (SSR) twice: -// - `plain`: no Sentry plugin. -// - `plugin`: with `sentryVitePlugin` (build-time instrumentation). +// Bundles the entrypoint with Vite (SSR) four ways, each a directly-runnable ESM bundle: +// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryVitePlugin`) +// build-time instruments it. Run without `--import`. +// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can +// intercept it at load time. Run with `--import`. // The Sentry vite plugin's build-time code transform only applies to server builds (it gates itself -// on `consumer === 'server'`), so this uses an SSR build rather than a client `lib` build. Only the -// `plugin` build then injects the orchestrion "bundler ran" banner into the entry chunk. Kept -// unminified so the banner keeps its identifiers (a minifier would rename them); assert.mjs matches -// it whitespace-insensitively. +// on `consumer === 'server'`), so this uses an SSR build rather than a client `lib` build. +// `assert.mjs` runs all four and checks the query works and that exactly one set of graphql spans is +// emitted in each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers. +import { rmSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,10 +15,29 @@ import { build } from 'vite'; import { sentryVitePlugin } from '@sentry/node/vite'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const nodeExternals = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; -function run(name, plugins) { +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + sentryVitePlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +function run(name, { graphqlExternal, plugins }) { return build({ logLevel: 'silent', + // Whether graphql is inlined or external is governed by Vite's SSR externalization + // (`ssr.external` / `ssr.noExternal`), NOT `rollupOptions.external`: a Vite SSR build + // externalizes node_modules deps by default, so without an explicit `ssr.noExternal` graphql + // stays external no matter what `rollupOptions.external` says. Set it per-variant so the + // build-time (inlined) and runtime (external) paths are each genuinely exercised. For the + // `*-external` variants `ssr.external` also wins over the plugin's own `noExternal` force-bundle, + // so graphql is left for the runtime `--import` hook to transform as it loads from node_modules. + ssr: graphqlExternal ? { external: ['graphql'] } : { noExternal: ['graphql'] }, build: { outDir: join(__dirname, 'dist', name), emptyOutDir: true, @@ -27,7 +48,7 @@ function run(name, plugins) { // SSR build so the plugin's build-time transform applies (it only runs for server builds). ssr: join(__dirname, 'src', 'entry.mjs'), rollupOptions: { - external: [...builtinModules, ...builtinModules.map(m => `node:${m}`)], + external: nodeExternals, output: { entryFileNames: 'main.mjs', format: 'es' }, }, }, @@ -35,18 +56,10 @@ function run(name, plugins) { }); } -await run('plain', []); -await run( - 'plugin', - // No auth/release/telemetry — we only care about the build-time transforms and defines. - [ - sentryVitePlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), - ], -); +await run('plain', { graphqlExternal: false, plugins: [] }); +await run('plugin', { graphqlExternal: false, plugins: [makeSentryPlugin()] }); +await run('plain-external', { graphqlExternal: true, plugins: [] }); +await run('plugin-external', { graphqlExternal: true, plugins: [makeSentryPlugin()] }); // eslint-disable-next-line no-console -console.log('built plain + plugin with vite'); +console.log('built plain + plugin (inlined) and plain-external + plugin-external with vite'); diff --git a/dev-packages/e2e-tests/test-applications/node-vite/package.json b/dev-packages/e2e-tests/test-applications/node-vite/package.json index d6d10a6c260c..22988b563137 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/node-vite/package.json @@ -1,6 +1,6 @@ { "name": "node-vite", - "description": "ensure the Sentry vite plugin performs build-time instrumentation", + "description": "ensure the Sentry vite plugin build-time instruments a bundled graphql at runtime", "version": "1.0.0", "private": true, "type": "module", @@ -15,6 +15,8 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "graphql": "16.9.0", "vite": "6.4.3" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/node-vite/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-vite/src/app.mjs index e66db6685328..40666ff77ead 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion +// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range +// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay +// library-agnostic. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-vite/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-vite/src/entry.mjs index 5c03b545d672..c59d82e4ff24 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/src/entry.mjs @@ -1,9 +1,48 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written +// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return +// value rides along as `result` so the assertion can check it without knowing what the workload does. +// +// The body is an async function rather than top-level await so the same source bundles to both ESM +// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await). +import { writeFileSync } from 'node:fs'; import * as Sentry from '@sentry/node'; -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, -}); +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); -await import('./app.mjs'); + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + // Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit + // can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs b/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs index 4f2279c837e2..cb58bfa5c839 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs @@ -1,61 +1,7 @@ -/** - * Asserts that `sentryWebpackPlugin` performs build-time instrumentation: its code transform injects - * the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not. - * - * @module - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { assertBundlerInstrumentation } from '@sentry-internal/test-utils'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform -// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`). -// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build -// apart from a `plain` one. Before matching we strip block comments and whitespace, because bundlers -// format the injected banner differently — Rolldown pretty-prints it and inserts a `/* @__PURE__ */` -// annotation. The banner initializes the set with `new Set()`, hence the stripped `newSet()` form. -const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||newSet()'; - -function bundleText(name) { - const files = []; - const walk = dir => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else { - files.push(full); - } - } - }; - walk(join(__dirname, 'dist', name)); - return files - .map(f => readFileSync(f, 'utf8')) - .join('\n') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\s+/g, ''); -} - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); - -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); -check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryWebpackPlugin runs build-time instrumentation (injects the orchestrion banner)', -); - -if (failed) { - process.exit(1); -} -// eslint-disable-next-line no-console -console.log('All bundle assertions passed.'); +// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the +// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each +// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation` +// in `@sentry-internal/test-utils` for the full matrix. +assertBundlerInstrumentation('graphql'); diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/build.mjs b/dev-packages/e2e-tests/test-applications/node-webpack/build.mjs index eb85a581f05d..f58330c6fcd9 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/build.mjs @@ -1,9 +1,12 @@ -// Bundles the entrypoint with webpack twice: -// - `plain`: no Sentry plugin. -// - `plugin`: with `sentryWebpackPlugin` (build-time instrumentation). -// Only the `plugin` build runs the orchestrion code transform, which injects the "bundler ran" banner -// into the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would -// rename them); assert.mjs matches it whitespace-insensitively. +// Bundles the entrypoint with webpack four ways, each a directly-runnable ESM bundle: +// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryWebpackPlugin`) +// build-time instruments it. Run without `--import`. +// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can +// intercept it at load time. Run with `--import`. +// `assert.mjs` runs all four and checks the graphql query works and that exactly one set of graphql +// spans is emitted in each instrumented scenario (build-time or runtime, never both/double). +// Kept unminified so the injected snippet keeps its identifiers. +import { rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import webpack from 'webpack'; @@ -11,7 +14,17 @@ import { sentryWebpackPlugin } from '@sentry/node/webpack'; const __dirname = dirname(fileURLToPath(import.meta.url)); -function build(name, plugins) { +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + +// No auth/release/telemetry — we only care about the build-time transforms and defines. +const makeSentryPlugin = () => + sentryWebpackPlugin({ + telemetry: false, + sourcemaps: { disable: true }, + release: { create: false, finalize: false, inject: false }, + }); + +function build(name, { external, plugins }) { return new Promise((resolve, reject) => { webpack( { @@ -19,11 +32,14 @@ function build(name, plugins) { mode: 'production', target: 'node', experiments: { topLevelAwait: true, outputModule: true }, + externalsType: 'module', + // The `*-external` variants keep graphql out of the bundle, so it is resolved from + // node_modules at runtime and the `--import` hook can transform it as it loads. + externals: external ? { graphql: 'module graphql' } : {}, output: { path: join(__dirname, 'dist', name), filename: 'main.mjs', module: true, - library: { type: 'module' }, chunkFormat: 'module', }, optimization: { minimize: false }, @@ -42,15 +58,7 @@ function build(name, plugins) { }); } -await build('plain', []); -await build( - 'plugin', - // No auth/release/telemetry — we only care about the build-time transforms and defines. - [ - sentryWebpackPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), - ], -); +await build('plain', { external: false, plugins: [] }); +await build('plugin', { external: false, plugins: [makeSentryPlugin()] }); +await build('plain-external', { external: true, plugins: [] }); +await build('plugin-external', { external: true, plugins: [makeSentryPlugin()] }); diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-webpack/package.json index 9b82d38b838f..37da560ad577 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/package.json +++ b/dev-packages/e2e-tests/test-applications/node-webpack/package.json @@ -1,6 +1,6 @@ { "name": "node-webpack", - "description": "ensure the Sentry webpack plugin performs build-time instrumentation", + "description": "ensure the Sentry webpack plugin build-time instruments a bundled graphql at runtime", "version": "1.0.0", "private": true, "type": "module", @@ -15,6 +15,8 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "graphql": "16.9.0", "webpack": "5.107.2" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-webpack/src/app.mjs index e66db6685328..40666ff77ead 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the +// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion +// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range +// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay +// library-agnostic. +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runWorkload() { + return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-webpack/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-webpack/src/entry.mjs index 5c03b545d672..c59d82e4ff24 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/src/entry.mjs @@ -1,9 +1,48 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the instrumentation's channel subscriber is ready, then the workload is imported and run. +// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written +// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return +// value rides along as `result` so the assertion can check it without knowing what the workload does. +// +// The body is an async function rather than top-level await so the same source bundles to both ESM +// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await). +import { writeFileSync } from 'node:fs'; import * as Sentry from '@sentry/node'; -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, -}); +async function main() { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible + // injector, so a `plain` (no-plugin) build is a true negative. + enableRuntimeChannelInjection: false, + // Hermetic — never hit the network. + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); -await import('./app.mjs'); + const spans = []; + Sentry.getClient()?.on('spanEnd', span => { + const json = Sentry.spanToJSON(span); + spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); + }); + + const { runWorkload } = await import('./app.mjs'); + + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); + }); + + await Sentry.flush(2000); + + const resultFile = process.env.SENTRY_E2E_RESULT_FILE; + if (!resultFile) { + throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).'); + } + // Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit + // can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). + writeFileSync(resultFile, JSON.stringify({ result, spans })); + process.exit(0); +} + +void main(); diff --git a/dev-packages/test-utils/src/build-output.ts b/dev-packages/test-utils/src/build-output.ts index ef0e4f03d312..a3c2445e9f8a 100644 --- a/dev-packages/test-utils/src/build-output.ts +++ b/dev-packages/test-utils/src/build-output.ts @@ -40,6 +40,23 @@ const SPECIFIER_PATTERNS = [ /\bfrom\s*["']([^"']+)["']/g, ]; +/** + * Whether the emitted bundle still imports/requires `moduleName` as a bare specifier — i.e. the + * module was left external instead of inlined into the bundle. Matches ESM `from ''`, dynamic + * `import('')` and CJS `require('')`, and compares the specifier exactly, so `graphql` never + * matches `graphql/execution`. + */ +export function bundleReferencesModule(bundleContents: string, moduleName: string): boolean { + for (const pattern of SPECIFIER_PATTERNS) { + for (const match of bundleContents.matchAll(pattern)) { + if (match[1] === moduleName) { + return true; + } + } + } + return false; +} + /** * Returns every absolute-path module specifier in the emitted output, as ``. * diff --git a/dev-packages/test-utils/src/bundler-instrumentation.ts b/dev-packages/test-utils/src/bundler-instrumentation.ts new file mode 100644 index 000000000000..3e14a4084f74 --- /dev/null +++ b/dev-packages/test-utils/src/bundler-instrumentation.ts @@ -0,0 +1,197 @@ +import { execFileSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { existsSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { bundleReferencesModule } from './build-output'; + +const JS_EXTENSIONS = ['.mjs', '.cjs', '.js']; + +/** + * Describes a bundled first-party workload and how its auto-instrumentation shows up, so + * {@link assertBundlerInstrumentation} can drive the same build-time / runtime matrix for any + * instrumented library — not just graphql. + * + * The workload itself lives in the test app (`src/app.mjs`, exporting `runWorkload()`); this + * descriptor is the small amount the assertion needs to know about it. + */ +export interface InstrumentationFixture { + /** + * Bare specifier the workload imports the instrumented library by. Used to tell an *inlined* build + * (the library's source is bundled in) from an *external* one (a bare `import`/`require` left for + * the runtime `--import` hook to intercept). + */ + moduleName: string; + /** `sentry.origin` the instrumentation stamps on the spans it emits. */ + origin: string; + /** + * An identifier that appears only in the library's own source. Present in the bundle iff the + * library was inlined, so it separates an inlined build from an external one. + */ + sourceMarker: string; + /** Validates the workload's result — the value `runWorkload()` resolved to, round-tripped as JSON. */ + assertResult: (result: unknown) => boolean; +} + +const FIXTURES: Record = { + graphql: { + moduleName: 'graphql', + origin: 'auto.graphql.diagnostic_channel', + sourceMarker: 'GraphQLSchema', + assertResult: result => (result as { data?: { hello?: string } })?.data?.hello === 'world', + }, +}; + +interface BundleRun { + result: unknown; + spans: Array<{ name?: string; origin?: string }>; +} + +/** + * Runs a bundler test app's four built bundles across the build-time and runtime instrumentation + * paths and asserts that each instrumented scenario emits exactly one set of spans — never zero, + * never double: + * + * - `plain` (inlined, no plugin, no `--import`): no spans (negative control), + * - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection, + * - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook, + * - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument an + * external module, so the runtime hook is + * the sole injector and there's no double + * instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and library versions. It also asserts the bundle *shape* (inlined vs external) directly, + * so an externalization knob that silently becomes a no-op fails here instead of passing by accident. + * A boot crash surfaces as a non-zero child exit / missing result file rather than being silently + * swallowed. + * + * @param fixtureOrName A built-in fixture name (e.g. `'graphql'`) or an {@link InstrumentationFixture}. + * @param options.appDir The test app directory holding `dist//main.*`. Defaults to `cwd`. + */ +export function assertBundlerInstrumentation( + fixtureOrName: string | InstrumentationFixture, + { appDir = process.cwd() }: { appDir?: string } = {}, +): void { + const fixture = typeof fixtureOrName === 'string' ? FIXTURES[fixtureOrName] : fixtureOrName; + if (!fixture) { + throw new Error( + `Unknown instrumentation fixture "${fixtureOrName}". Known: ${Object.keys(FIXTURES).join(', ')}. ` + + 'Pass an InstrumentationFixture object to test a library without a built-in fixture.', + ); + } + const { moduleName, origin, sourceMarker, assertResult } = fixture; + + // Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's CJS output). + const entryPath = (name: string): string => { + const dir = join(appDir, 'dist', name); + const entry = ['main.mjs', 'main.cjs', 'main.js'].map(f => join(dir, f)).find(existsSync); + if (!entry) { + throw new Error(`no built entry (main.mjs/.cjs/.js) found in ${dir}`); + } + return entry; + }; + + // `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms the library as + // Node loads it — the mechanism used for external (unbundled) dependencies. The entry writes its + // result to a file (not stdout) so a piped, buffered write can't be truncated by the child's exit. + const runBundle = (name: string, withImport: boolean): BundleRun => { + const resultFile = join(tmpdir(), `sentry-bundler-e2e-${name}-${randomUUID()}.json`); + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const label = `${name}${withImport ? ' (--import)' : ''}`; + let stdout: string; + try { + stdout = execFileSync(process.execPath, args, { + encoding: 'utf8', + cwd: appDir, + env: { ...process.env, SENTRY_E2E_RESULT_FILE: resultFile }, + }); + } catch (error) { + const err = error as { stdout?: string | null; stderr?: string | null }; + throw new Error(`${label} crashed before writing its result.\n${err.stdout ?? ''}${err.stderr ?? ''}`); + } + let raw: string; + try { + raw = readFileSync(resultFile, 'utf8'); + } catch { + throw new Error(`${label} exited without writing a result file. Output:\n${stdout}`); + } finally { + rmSync(resultFile, { force: true }); + } + return JSON.parse(raw) as BundleRun; + }; + + const spanCount = (run: BundleRun): number => run.spans.filter(s => s.origin === origin).length; + + let failed = false; + const check = (condition: boolean, message: string): void => { + // eslint-disable-next-line no-console + console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); + if (!condition) { + failed = true; + } + }; + + // Every emitted `.mjs`/`.cjs`/`.js` chunk in the variant's output dir, concatenated. Scans the whole + // dir, not just the entry, because bundlers split dynamic imports (e.g. webpack turns the entry's + // `await import('./app.mjs')` into a separate chunk) — so the library's source (inlined) or its bare + // import (external) can land in a sibling chunk rather than `main.*`. + const readBundle = (name: string): string => + readdirSync(join(appDir, 'dist', name), { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && JS_EXTENSIONS.some(ext => entry.name.endsWith(ext))) + .map(entry => readFileSync(join(entry.parentPath, entry.name), 'utf8')) + .join('\n'); + + // Guards the build config, not just its runtime output: the span assertions can pass by accident + // when the externalization knob is a no-op (e.g. a Vite SSR build externalizes deps by default, so + // a mis-set toggle silently ships an external library in every variant while the counts still come + // out right). Assert the bundle SHAPE instead: an inlined build carries the library's own source + // (its `sourceMarker`) and has no bare import left; an external build keeps the bare import/require + // and never inlines that source. + const assertBundleShape = (name: string, inlined: boolean): void => { + const bundle = readBundle(name); + const external = bundleReferencesModule(bundle, moduleName); + const inlinedSource = bundle.includes(sourceMarker); + if (inlined) { + check(!external && inlinedSource, `${name}: ${moduleName} is inlined into the bundle`); + } else { + check(external && !inlinedSource, `${name}: ${moduleName} is kept external to the bundle`); + } + }; + + const scenarios = { + plain: runBundle('plain', false), + plugin: runBundle('plugin', false), + plainExternalImport: runBundle('plain-external', true), + pluginExternalImport: runBundle('plugin-external', true), + }; + + for (const [label, run] of Object.entries(scenarios)) { + check(assertResult(run.result), `${label}: ${moduleName} workload works`); + } + + assertBundleShape('plain', true); + assertBundleShape('plugin', true); + assertBundleShape('plain-external', false); + assertBundleShape('plugin-external', false); + + // One set of spans, established by the build-time run. + const oneSet = spanCount(scenarios.plugin); + check(oneSet > 0, `plugin build (build-time) emits a set of ${moduleName} spans`); + check(spanCount(scenarios.plain) === 0, `plain build (no plugin, no --import) emits no ${moduleName} spans`); + check( + spanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of ${moduleName} spans (${oneSet}) via the runtime hook`, + ); + check( + spanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of ${moduleName} spans (${oneSet}), not double`, + ); + + if (failed) { + throw new Error(`${moduleName} bundler instrumentation assertions failed`); + } + // eslint-disable-next-line no-console + console.log(`All ${moduleName} bundle assertions passed.`); +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 3ee7cb7ef2b5..cff6d479d00d 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -20,9 +20,13 @@ export { findSourceMapFiles, findSourceMappingUrlComments, findInjectedDebugIds, + bundleReferencesModule, } from './build-output'; export type { OutputScanOptions } from './build-output'; +export { assertBundlerInstrumentation } from './bundler-instrumentation'; +export type { InstrumentationFixture } from './bundler-instrumentation'; + export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server';