From 656420c7eb016145a3cf25598754c45fb2296142 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 10:40:05 +0200 Subject: [PATCH 1/6] ci: Skip Playwright install for e2e apps without @playwright/test Move the "does this app need Playwright" decision into the install-playwright action: it resolves @playwright/test from the app and, when the package isn't there, emits an empty version and skips the browser install (and its cache steps) instead of failing on the missing require. Apps that assert via a plain node script (e.g. the node bundler apps) no longer pay for a Playwright install they don't use, with no per-app config or extra workflow step to maintain. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/install-playwright/action.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 From c6a89f2f4d4d8c190d5da7fefb38bdab5d95c98e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 10:19:23 +0200 Subject: [PATCH 2/6] test(e2e): Run bundled graphql through node bundler apps and assert instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the node webpack/vite/rollup/rolldown/esbuild bundler apps from a static banner-grep into a runtime test: each app bundles a real `graphql` workload (inlined, only node builtins external) twice — `plain` (no plugin) and `plugin` (Sentry bundler plugin) — then runs both built bundles and asserts the query still returns data and that only the `plugin` build emits `auto.graphql.diagnostic_channel` spans. The entry disables `enableRuntimeChannelInjection` and runs without `--import`, so the bundler plugin is the only possible injector, making the `plain` build a true negative. Spans are captured via the `spanEnd` hook (transport/lifecycle-independent). The entry body is an async function (not top-level await) so it bundles to both ESM and esbuild's CJS node output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-applications/node-esbuild/assert.mjs | 73 ++++++++++--------- .../test-applications/node-esbuild/build.mjs | 20 +++-- .../node-esbuild/package.json | 3 +- .../node-esbuild/src/app.mjs | 12 ++- .../node-esbuild/src/entry.mjs | 45 ++++++++++-- .../node-rolldown/assert.mjs | 73 ++++++++++--------- .../test-applications/node-rolldown/build.mjs | 16 ++-- .../node-rolldown/package.json | 3 +- .../node-rolldown/src/app.mjs | 12 ++- .../node-rolldown/src/entry.mjs | 45 ++++++++++-- .../test-applications/node-rollup/assert.mjs | 73 ++++++++++--------- .../test-applications/node-rollup/build.mjs | 16 ++-- .../node-rollup/package.json | 3 +- .../test-applications/node-rollup/src/app.mjs | 12 ++- .../node-rollup/src/entry.mjs | 45 ++++++++++-- .../test-applications/node-vite/assert.mjs | 73 ++++++++++--------- .../test-applications/node-vite/build.mjs | 18 +++-- .../test-applications/node-vite/package.json | 3 +- .../test-applications/node-vite/src/app.mjs | 12 ++- .../test-applications/node-vite/src/entry.mjs | 45 ++++++++++-- .../test-applications/node-webpack/assert.mjs | 73 ++++++++++--------- .../test-applications/node-webpack/build.mjs | 17 +++-- .../node-webpack/package.json | 3 +- .../node-webpack/src/app.mjs | 12 ++- .../node-webpack/src/entry.mjs | 45 ++++++++++-- 25 files changed, 503 insertions(+), 249 deletions(-) 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..8f217867bc13 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs @@ -1,41 +1,39 @@ /** - * 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. + * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: + * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the + * bundle at boot), + * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, + * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * + * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert + * rather than being silently swallowed. * * @module */ -import { readdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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, ''); +const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; + +// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). +function entryPath(name) { + const dir = join(__dirname, '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; +} + +function runBundle(name) { + const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); + const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); + if (!line) { + throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + } + return JSON.parse(line.slice('__RESULT__'.length)); } let failed = false; @@ -45,13 +43,20 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); +const plain = runBundle('plain'); +const plugin = runBundle('plugin'); -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); +const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); + +check(plain.data?.hello === 'world', 'plain build: graphql query works'); +check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check( + !hasGraphqlOrigin(plain), + 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', +); check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryEsbuildPlugin runs build-time instrumentation (injects the orchestrion banner)', + hasGraphqlOrigin(plugin), + 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', ); if (failed) { 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..e9140e11f928 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,11 @@ -// 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 twice, each a directly-runnable bundle with `graphql` inlined +// (only node builtins stay external): +// - `plain`: no Sentry plugin -> graphql is not instrumented. +// - `plugin`: with `sentryEsbuildPlugin` -> the orchestrion transform instruments graphql at build +// time. +// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. +// 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,13 +13,15 @@ import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; const __dirname = dirname(fileURLToPath(import.meta.url)); +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + function run(name, 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', minify: false, logLevel: 'silent', plugins, 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..bf6cee62844c 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,7 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "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..6beffdc12191 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,10 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle +// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. +// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runGraphqlQuery() { + 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..f6fe03de6938 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,42 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are +// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a +// single machine-readable line for `assert.mjs`. +// +// 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 * 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 { runGraphqlQuery } = await import('./app.mjs'); + + let data; + await Sentry.startSpan({ name: 'graphql-work' }, async () => { + const result = await runGraphqlQuery(); + data = result.data; + }); + + await Sentry.flush(2000); + + // eslint-disable-next-line no-console + console.log(`__RESULT__${JSON.stringify({ data, 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..8f217867bc13 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs @@ -1,41 +1,39 @@ /** - * 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. + * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: + * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the + * bundle at boot), + * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, + * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * + * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert + * rather than being silently swallowed. * * @module */ -import { readdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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, ''); +const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; + +// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). +function entryPath(name) { + const dir = join(__dirname, '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; +} + +function runBundle(name) { + const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); + const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); + if (!line) { + throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + } + return JSON.parse(line.slice('__RESULT__'.length)); } let failed = false; @@ -45,13 +43,20 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); +const plain = runBundle('plain'); +const plugin = runBundle('plugin'); -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); +const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); + +check(plain.data?.hello === 'world', 'plain build: graphql query works'); +check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check( + !hasGraphqlOrigin(plain), + 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', +); check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)', + hasGraphqlOrigin(plugin), + 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', ); if (failed) { 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..da463e4b61af 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 twice, each a directly-runnable ESM bundle with `graphql` +// inlined (only node builtins stay external): +// - `plain`: no Sentry plugin -> graphql is not instrumented. +// - `plugin`: with `sentryRollupPlugin` -> the orchestrion transform instruments graphql at build +// time. // 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 both bundles and checks the graphql query works and which auto-spans appear. +// 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'; @@ -15,6 +17,8 @@ import { sentryRollupPlugin } from '@sentry/node/rollup'; const __dirname = dirname(fileURLToPath(import.meta.url)); const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + async function run(name, extra) { const bundle = await rolldown({ input: join(__dirname, 'src', 'entry.mjs'), 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..e850deda32d9 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,7 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "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..6beffdc12191 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,10 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle +// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. +// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runGraphqlQuery() { + 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..f6fe03de6938 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,42 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are +// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a +// single machine-readable line for `assert.mjs`. +// +// 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 * 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 { runGraphqlQuery } = await import('./app.mjs'); + + let data; + await Sentry.startSpan({ name: 'graphql-work' }, async () => { + const result = await runGraphqlQuery(); + data = result.data; + }); + + await Sentry.flush(2000); + + // eslint-disable-next-line no-console + console.log(`__RESULT__${JSON.stringify({ data, 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..8f217867bc13 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs @@ -1,41 +1,39 @@ /** - * 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. + * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: + * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the + * bundle at boot), + * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, + * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * + * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert + * rather than being silently swallowed. * * @module */ -import { readdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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, ''); +const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; + +// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). +function entryPath(name) { + const dir = join(__dirname, '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; +} + +function runBundle(name) { + const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); + const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); + if (!line) { + throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + } + return JSON.parse(line.slice('__RESULT__'.length)); } let failed = false; @@ -45,13 +43,20 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); +const plain = runBundle('plain'); +const plugin = runBundle('plugin'); -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); +const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); + +check(plain.data?.hello === 'world', 'plain build: graphql query works'); +check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check( + !hasGraphqlOrigin(plain), + 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', +); check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)', + hasGraphqlOrigin(plugin), + 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', ); if (failed) { 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..04910e92972f 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 twice, each a directly-runnable ESM bundle with `graphql` +// inlined (only node builtins stay external): +// - `plain`: no Sentry plugin -> graphql is not instrumented. +// - `plugin`: with `sentryRollupPlugin` -> the orchestrion transform instruments graphql at build +// time. +// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. +// 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'; @@ -15,6 +17,8 @@ import { sentryRollupPlugin } from '@sentry/node/rollup'; const __dirname = dirname(fileURLToPath(import.meta.url)); const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + async function run(name, extra) { const bundle = await rollup({ input: join(__dirname, 'src', 'entry.mjs'), 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..d0571017dac6 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,7 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "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..6beffdc12191 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,10 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle +// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. +// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runGraphqlQuery() { + 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..f6fe03de6938 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,42 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are +// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a +// single machine-readable line for `assert.mjs`. +// +// 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 * 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 { runGraphqlQuery } = await import('./app.mjs'); + + let data; + await Sentry.startSpan({ name: 'graphql-work' }, async () => { + const result = await runGraphqlQuery(); + data = result.data; + }); + + await Sentry.flush(2000); + + // eslint-disable-next-line no-console + console.log(`__RESULT__${JSON.stringify({ data, 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..8f217867bc13 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs @@ -1,41 +1,39 @@ /** - * 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. + * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: + * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the + * bundle at boot), + * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, + * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * + * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert + * rather than being silently swallowed. * * @module */ -import { readdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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, ''); +const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; + +// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). +function entryPath(name) { + const dir = join(__dirname, '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; +} + +function runBundle(name) { + const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); + const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); + if (!line) { + throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + } + return JSON.parse(line.slice('__RESULT__'.length)); } let failed = false; @@ -45,13 +43,20 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); +const plain = runBundle('plain'); +const plugin = runBundle('plugin'); -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); +const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); + +check(plain.data?.hello === 'world', 'plain build: graphql query works'); +check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check( + !hasGraphqlOrigin(plain), + 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', +); check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryVitePlugin runs build-time instrumentation (injects the orchestrion banner)', + hasGraphqlOrigin(plugin), + 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', ); if (failed) { 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..aa9bd77c44c0 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) twice, each a directly-runnable ESM bundle with `graphql` +// inlined (only node builtins stay external): +// - `plain`: no Sentry plugin -> graphql is not instrumented. +// - `plugin`: with `sentryVitePlugin` -> the orchestrion transform instruments graphql at build +// time. // 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 both bundles and checks the graphql query works and which auto-spans appear. +// 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'; @@ -14,6 +16,8 @@ import { sentryVitePlugin } from '@sentry/node/vite'; const __dirname = dirname(fileURLToPath(import.meta.url)); +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + function run(name, plugins) { return build({ logLevel: 'silent', 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..d17a47581e0a 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,7 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "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..6beffdc12191 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,10 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle +// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. +// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runGraphqlQuery() { + 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..f6fe03de6938 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,42 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are +// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a +// single machine-readable line for `assert.mjs`. +// +// 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 * 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 { runGraphqlQuery } = await import('./app.mjs'); + + let data; + await Sentry.startSpan({ name: 'graphql-work' }, async () => { + const result = await runGraphqlQuery(); + data = result.data; + }); + + await Sentry.flush(2000); + + // eslint-disable-next-line no-console + console.log(`__RESULT__${JSON.stringify({ data, 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..8f217867bc13 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs @@ -1,41 +1,39 @@ /** - * 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. + * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: + * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the + * bundle at boot), + * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, + * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * + * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert + * rather than being silently swallowed. * * @module */ -import { readdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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, ''); +const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; + +// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). +function entryPath(name) { + const dir = join(__dirname, '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; +} + +function runBundle(name) { + const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); + const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); + if (!line) { + throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + } + return JSON.parse(line.slice('__RESULT__'.length)); } let failed = false; @@ -45,13 +43,20 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = bundleText('plain'); -const plugin = bundleText('plugin'); +const plain = runBundle('plain'); +const plugin = runBundle('plugin'); -check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation'); +const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); + +check(plain.data?.hello === 'world', 'plain build: graphql query works'); +check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check( + !hasGraphqlOrigin(plain), + 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', +); check( - plugin.includes(BUILD_TIME_TRANSFORM_MARKER), - 'sentryWebpackPlugin runs build-time instrumentation (injects the orchestrion banner)', + hasGraphqlOrigin(plugin), + 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', ); if (failed) { 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..bb7f0d5c1ce5 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,11 @@ -// 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 twice, each a directly-runnable ESM bundle with `graphql` +// inlined (only node builtins stay external): +// - `plain`: no Sentry plugin -> graphql is not instrumented. +// - `plugin`: with `sentryWebpackPlugin` -> the orchestrion transform instruments graphql at build +// time. +// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. +// 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,6 +13,8 @@ import { sentryWebpackPlugin } from '@sentry/node/webpack'; const __dirname = dirname(fileURLToPath(import.meta.url)); +rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + function build(name, plugins) { return new Promise((resolve, reject) => { webpack( @@ -23,7 +27,6 @@ function build(name, plugins) { path: join(__dirname, 'dist', name), filename: 'main.mjs', module: true, - library: { type: 'module' }, chunkFormat: 'module', }, optimization: { minimize: false }, 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..1da81a3c9065 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,7 @@ "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz" }, "devDependencies": { + "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..6beffdc12191 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,10 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle +// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. +// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +import { buildSchema, graphql } from 'graphql'; + +const schema = buildSchema('type Query { hello: String }'); + +export async function runGraphqlQuery() { + 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..f6fe03de6938 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,42 @@ +// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs +// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are +// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a +// single machine-readable line for `assert.mjs`. +// +// 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 * 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 { runGraphqlQuery } = await import('./app.mjs'); + + let data; + await Sentry.startSpan({ name: 'graphql-work' }, async () => { + const result = await runGraphqlQuery(); + data = result.data; + }); + + await Sentry.flush(2000); + + // eslint-disable-next-line no-console + console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + process.exit(0); +} + +void main(); From a08c3dd03d8445ebb12170030142ff0ca25bcd5a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 10:43:07 +0200 Subject: [PATCH 3/6] test(e2e): Cover the runtime --import path and assert exactly one span set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two more variants per bundler app that keep graphql external and run the built bundle with `node --import @sentry/node/import`, so the runtime diagnostics-channel hook instruments graphql at load time (the inlined variants exercise the build-time transform instead). Each app now runs four scenarios: - plain (inlined, no plugin, no --import): no graphql spans (control) - plugin (inlined, plugin, no --import): one set, build-time - plain-external (external, no plugin, --import): one set, runtime hook - plugin-external (external, plugin, --import): one set, runtime hook only The assert defines "one set" relative to the build-time run and checks every instrumented scenario emits exactly that count — never zero, never double. The plugin-external + --import case in particular proves the build-time plugin and the runtime hook don't both instrument the same module (the plugin can't touch an external dep, so the runtime hook is the sole injector). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-applications/node-esbuild/assert.mjs | 60 ++++++++++++------- .../test-applications/node-esbuild/build.mjs | 47 ++++++++------- .../node-rolldown/assert.mjs | 60 ++++++++++++------- .../test-applications/node-rolldown/build.mjs | 50 +++++++++------- .../test-applications/node-rollup/assert.mjs | 60 ++++++++++++------- .../test-applications/node-rollup/build.mjs | 50 +++++++++------- .../test-applications/node-vite/assert.mjs | 60 ++++++++++++------- .../test-applications/node-vite/build.mjs | 47 ++++++++------- .../test-applications/node-webpack/assert.mjs | 60 ++++++++++++------- .../test-applications/node-webpack/build.mjs | 43 +++++++------ 10 files changed, 329 insertions(+), 208 deletions(-) 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 8f217867bc13..e568f8a5afcc 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs @@ -1,12 +1,18 @@ /** - * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: - * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the - * bundle at boot), - * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, - * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that + * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: * - * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert - * rather than being silently swallowed. + * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no + * double instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` + * line, which fails the assert rather than being silently swallowed. * * @module */ @@ -27,15 +33,30 @@ function entryPath(name) { return entry; } -function runBundle(name) { - const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); +// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node +// loads it — the mechanism used for external (unbundled) dependencies. +function run(name, { withImport = false } = {}) { + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); if (!line) { - throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); } return JSON.parse(line.slice('__RESULT__'.length)); } +const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; + +const scenarios = { + plain: run('plain'), + plugin: run('plugin'), + plainExternalImport: run('plain-external', { withImport: true }), + pluginExternalImport: run('plugin-external', { withImport: true }), +}; + +// One set of graphql spans, established by the build-time run. +const oneSet = graphqlSpanCount(scenarios.plugin); + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -43,20 +64,19 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = runBundle('plain'); -const plugin = runBundle('plugin'); - -const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); +for (const [label, result] of Object.entries(scenarios)) { + check(result.data?.hello === 'world', `${label}: graphql query works`); +} -check(plain.data?.hello === 'world', 'plain build: graphql query works'); -check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); +check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( - !hasGraphqlOrigin(plain), - 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, ); check( - hasGraphqlOrigin(plugin), - 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, ); if (failed) { 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 e9140e11f928..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,10 +1,12 @@ -// Bundles the entrypoint with esbuild twice, each a directly-runnable bundle with `graphql` inlined -// (only node builtins stay external): -// - `plain`: no Sentry plugin -> graphql is not instrumented. -// - `plugin`: with `sentryEsbuildPlugin` -> the orchestrion transform instruments graphql at build -// time. -// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. -// Kept unminified so the injected snippet keeps its identifiers. +// 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'; @@ -15,31 +17,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); -function run(name, plugins) { +// 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')], outfile: join(__dirname, 'dist', name, 'main.cjs'), bundle: true, platform: 'node', 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-rolldown/assert.mjs b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs index 8f217867bc13..e568f8a5afcc 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs @@ -1,12 +1,18 @@ /** - * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: - * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the - * bundle at boot), - * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, - * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that + * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: * - * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert - * rather than being silently swallowed. + * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no + * double instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` + * line, which fails the assert rather than being silently swallowed. * * @module */ @@ -27,15 +33,30 @@ function entryPath(name) { return entry; } -function runBundle(name) { - const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); +// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node +// loads it — the mechanism used for external (unbundled) dependencies. +function run(name, { withImport = false } = {}) { + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); if (!line) { - throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); } return JSON.parse(line.slice('__RESULT__'.length)); } +const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; + +const scenarios = { + plain: run('plain'), + plugin: run('plugin'), + plainExternalImport: run('plain-external', { withImport: true }), + pluginExternalImport: run('plugin-external', { withImport: true }), +}; + +// One set of graphql spans, established by the build-time run. +const oneSet = graphqlSpanCount(scenarios.plugin); + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -43,20 +64,19 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = runBundle('plain'); -const plugin = runBundle('plugin'); - -const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); +for (const [label, result] of Object.entries(scenarios)) { + check(result.data?.hello === 'world', `${label}: graphql query works`); +} -check(plain.data?.hello === 'world', 'plain build: graphql query works'); -check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); +check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( - !hasGraphqlOrigin(plain), - 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, ); check( - hasGraphqlOrigin(plugin), - 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, ); if (failed) { 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 da463e4b61af..d290d53b39d6 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs @@ -1,12 +1,12 @@ -// Bundles the entrypoint with Rolldown twice, each a directly-runnable ESM bundle with `graphql` -// inlined (only node builtins stay external): -// - `plain`: no Sentry plugin -> graphql is not instrumented. -// - `plugin`: with `sentryRollupPlugin` -> the orchestrion transform instruments graphql at build -// time. +// 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 both bundles and checks the graphql query works and which auto-spans appear. -// Kept unminified so the injected snippet keeps its identifiers. +// `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'; @@ -15,32 +15,36 @@ 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}`)]; rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); -async function run(name, extra) { +// 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], 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-rollup/assert.mjs b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs index 8f217867bc13..e568f8a5afcc 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs @@ -1,12 +1,18 @@ /** - * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: - * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the - * bundle at boot), - * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, - * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that + * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: * - * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert - * rather than being silently swallowed. + * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no + * double instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` + * line, which fails the assert rather than being silently swallowed. * * @module */ @@ -27,15 +33,30 @@ function entryPath(name) { return entry; } -function runBundle(name) { - const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); +// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node +// loads it — the mechanism used for external (unbundled) dependencies. +function run(name, { withImport = false } = {}) { + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); if (!line) { - throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); } return JSON.parse(line.slice('__RESULT__'.length)); } +const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; + +const scenarios = { + plain: run('plain'), + plugin: run('plugin'), + plainExternalImport: run('plain-external', { withImport: true }), + pluginExternalImport: run('plugin-external', { withImport: true }), +}; + +// One set of graphql spans, established by the build-time run. +const oneSet = graphqlSpanCount(scenarios.plugin); + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -43,20 +64,19 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = runBundle('plain'); -const plugin = runBundle('plugin'); - -const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); +for (const [label, result] of Object.entries(scenarios)) { + check(result.data?.hello === 'world', `${label}: graphql query works`); +} -check(plain.data?.hello === 'world', 'plain build: graphql query works'); -check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); +check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( - !hasGraphqlOrigin(plain), - 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, ); check( - hasGraphqlOrigin(plugin), - 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, ); if (failed) { 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 04910e92972f..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,10 +1,10 @@ -// Bundles the entrypoint with Rollup twice, each a directly-runnable ESM bundle with `graphql` -// inlined (only node builtins stay external): -// - `plain`: no Sentry plugin -> graphql is not instrumented. -// - `plugin`: with `sentryRollupPlugin` -> the orchestrion transform instruments graphql at build -// time. -// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. -// Kept unminified so the injected snippet keeps its identifiers. +// 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'; @@ -15,32 +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}`)]; rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); -async function run(name, extra) { +// 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-vite/assert.mjs b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs index 8f217867bc13..e568f8a5afcc 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs @@ -1,12 +1,18 @@ /** - * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: - * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the - * bundle at boot), - * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, - * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that + * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: * - * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert - * rather than being silently swallowed. + * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no + * double instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` + * line, which fails the assert rather than being silently swallowed. * * @module */ @@ -27,15 +33,30 @@ function entryPath(name) { return entry; } -function runBundle(name) { - const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); +// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node +// loads it — the mechanism used for external (unbundled) dependencies. +function run(name, { withImport = false } = {}) { + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); if (!line) { - throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); } return JSON.parse(line.slice('__RESULT__'.length)); } +const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; + +const scenarios = { + plain: run('plain'), + plugin: run('plugin'), + plainExternalImport: run('plain-external', { withImport: true }), + pluginExternalImport: run('plugin-external', { withImport: true }), +}; + +// One set of graphql spans, established by the build-time run. +const oneSet = graphqlSpanCount(scenarios.plugin); + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -43,20 +64,19 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = runBundle('plain'); -const plugin = runBundle('plugin'); - -const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); +for (const [label, result] of Object.entries(scenarios)) { + check(result.data?.hello === 'world', `${label}: graphql query works`); +} -check(plain.data?.hello === 'world', 'plain build: graphql query works'); -check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); +check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( - !hasGraphqlOrigin(plain), - 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, ); check( - hasGraphqlOrigin(plugin), - 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, ); if (failed) { 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 aa9bd77c44c0..021af28f4624 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/build.mjs @@ -1,12 +1,12 @@ -// Bundles the entrypoint with Vite (SSR) twice, each a directly-runnable ESM bundle with `graphql` -// inlined (only node builtins stay external): -// - `plain`: no Sentry plugin -> graphql is not instrumented. -// - `plugin`: with `sentryVitePlugin` -> the orchestrion transform instruments graphql at build -// time. +// 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. -// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. -// Kept unminified so the injected snippet keeps its identifiers. +// `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'; @@ -15,10 +15,19 @@ 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}`)]; rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); -function run(name, plugins) { +// 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, { external, plugins }) { return build({ logLevel: 'silent', build: { @@ -31,7 +40,9 @@ 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}`)], + // 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, output: { entryFileNames: 'main.mjs', format: 'es' }, }, }, @@ -39,18 +50,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', { 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 vite'); +console.log('built plain + plugin (inlined) and plain-external + plugin-external with vite'); 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 8f217867bc13..e568f8a5afcc 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs @@ -1,12 +1,18 @@ /** - * Runs both built bundles and asserts that build-time instrumentation actually fires at runtime: - * - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the - * bundle at boot), - * - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`, - * - `plain` build: they do not (negative control — no plugin, runtime hook disabled). + * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that + * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: * - * A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert - * rather than being silently swallowed. + * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no + * double instrumentation. + * + * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across + * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` + * line, which fails the assert rather than being silently swallowed. * * @module */ @@ -27,15 +33,30 @@ function entryPath(name) { return entry; } -function runBundle(name) { - const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' }); +// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node +// loads it — the mechanism used for external (unbundled) dependencies. +function run(name, { withImport = false } = {}) { + const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); if (!line) { - throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`); + throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); } return JSON.parse(line.slice('__RESULT__'.length)); } +const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; + +const scenarios = { + plain: run('plain'), + plugin: run('plugin'), + plainExternalImport: run('plain-external', { withImport: true }), + pluginExternalImport: run('plugin-external', { withImport: true }), +}; + +// One set of graphql spans, established by the build-time run. +const oneSet = graphqlSpanCount(scenarios.plugin); + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -43,20 +64,19 @@ function check(condition, message) { if (!condition) failed = true; } -const plain = runBundle('plain'); -const plugin = runBundle('plugin'); - -const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN); +for (const [label, result] of Object.entries(scenarios)) { + check(result.data?.hello === 'world', `${label}: graphql query works`); +} -check(plain.data?.hello === 'world', 'plain build: graphql query works'); -check(plugin.data?.hello === 'world', 'plugin build: graphql query works'); +check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); +check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( - !hasGraphqlOrigin(plain), - 'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.plainExternalImport) === oneSet, + `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, ); check( - hasGraphqlOrigin(plugin), - 'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)', + graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, + `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, ); if (failed) { 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 bb7f0d5c1ce5..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,10 @@ -// Bundles the entrypoint with webpack twice, each a directly-runnable ESM bundle with `graphql` -// inlined (only node builtins stay external): -// - `plain`: no Sentry plugin -> graphql is not instrumented. -// - `plugin`: with `sentryWebpackPlugin` -> the orchestrion transform instruments graphql at build -// time. -// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear. +// 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'; @@ -15,7 +16,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); -function build(name, plugins) { +// 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( { @@ -23,6 +32,10 @@ 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', @@ -45,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()] }); From ad04c2db474bf6227822ebc33fdb4d330101b1dd Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 28 Aug 2026 09:19:46 +0200 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: isaacs --- dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs | 1 + 1 file changed, 1 insertion(+) 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 d290d53b39d6..2f0b067beeba 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs @@ -35,6 +35,7 @@ async function run(name, { external, plugins }) { // 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' }); From 2f168103ab4a847ff506a3672d2d65bc2a3926e7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 09:40:18 +0200 Subject: [PATCH 5/6] fixes --- .../test-applications/node-esbuild/assert.mjs | 28 ++++++++++++++++++- .../node-rolldown/assert.mjs | 28 ++++++++++++++++++- .../test-applications/node-rollup/assert.mjs | 28 ++++++++++++++++++- .../test-applications/node-vite/assert.mjs | 28 ++++++++++++++++++- .../test-applications/node-vite/build.mjs | 22 +++++++++------ .../test-applications/node-webpack/assert.mjs | 28 ++++++++++++++++++- 6 files changed, 149 insertions(+), 13 deletions(-) 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 e568f8a5afcc..58af83e04862 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs @@ -17,7 +17,7 @@ * @module */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,27 @@ function run(name, { withImport = false } = {}) { const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; +// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts +// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own +// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build +// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches +// ESM `from 'graphql'` and CJS `require('graphql')` alike. +const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; +const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; + +function assertBundleShape(name, { inlined }) { + const bundle = readFileSync(entryPath(name), 'utf8'); + const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); + const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); + if (inlined) { + check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); + } else { + check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); + } +} + const scenarios = { plain: run('plain'), plugin: run('plugin'), @@ -68,6 +89,11 @@ for (const [label, result] of Object.entries(scenarios)) { check(result.data?.hello === 'world', `${label}: graphql query works`); } +assertBundleShape('plain', { inlined: true }); +assertBundleShape('plugin', { inlined: true }); +assertBundleShape('plain-external', { inlined: false }); +assertBundleShape('plugin-external', { inlined: false }); + check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( 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 e568f8a5afcc..58af83e04862 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs @@ -17,7 +17,7 @@ * @module */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,27 @@ function run(name, { withImport = false } = {}) { const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; +// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts +// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own +// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build +// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches +// ESM `from 'graphql'` and CJS `require('graphql')` alike. +const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; +const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; + +function assertBundleShape(name, { inlined }) { + const bundle = readFileSync(entryPath(name), 'utf8'); + const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); + const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); + if (inlined) { + check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); + } else { + check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); + } +} + const scenarios = { plain: run('plain'), plugin: run('plugin'), @@ -68,6 +89,11 @@ for (const [label, result] of Object.entries(scenarios)) { check(result.data?.hello === 'world', `${label}: graphql query works`); } +assertBundleShape('plain', { inlined: true }); +assertBundleShape('plugin', { inlined: true }); +assertBundleShape('plain-external', { inlined: false }); +assertBundleShape('plugin-external', { inlined: false }); + check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( 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 e568f8a5afcc..58af83e04862 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-rollup/assert.mjs @@ -17,7 +17,7 @@ * @module */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,27 @@ function run(name, { withImport = false } = {}) { const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; +// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts +// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own +// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build +// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches +// ESM `from 'graphql'` and CJS `require('graphql')` alike. +const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; +const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; + +function assertBundleShape(name, { inlined }) { + const bundle = readFileSync(entryPath(name), 'utf8'); + const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); + const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); + if (inlined) { + check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); + } else { + check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); + } +} + const scenarios = { plain: run('plain'), plugin: run('plugin'), @@ -68,6 +89,11 @@ for (const [label, result] of Object.entries(scenarios)) { check(result.data?.hello === 'world', `${label}: graphql query works`); } +assertBundleShape('plain', { inlined: true }); +assertBundleShape('plugin', { inlined: true }); +assertBundleShape('plain-external', { inlined: false }); +assertBundleShape('plugin-external', { inlined: false }); + check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( 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 e568f8a5afcc..58af83e04862 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/assert.mjs @@ -17,7 +17,7 @@ * @module */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,27 @@ function run(name, { withImport = false } = {}) { const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; +// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts +// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own +// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build +// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches +// ESM `from 'graphql'` and CJS `require('graphql')` alike. +const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; +const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; + +function assertBundleShape(name, { inlined }) { + const bundle = readFileSync(entryPath(name), 'utf8'); + const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); + const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); + if (inlined) { + check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); + } else { + check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); + } +} + const scenarios = { plain: run('plain'), plugin: run('plugin'), @@ -68,6 +89,11 @@ for (const [label, result] of Object.entries(scenarios)) { check(result.data?.hello === 'world', `${label}: graphql query works`); } +assertBundleShape('plain', { inlined: true }); +assertBundleShape('plugin', { inlined: true }); +assertBundleShape('plain-external', { inlined: false }); +assertBundleShape('plugin-external', { inlined: false }); + check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( 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 021af28f4624..6b3e5e267905 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-vite/build.mjs @@ -27,9 +27,17 @@ const makeSentryPlugin = () => release: { create: false, finalize: false, inject: false }, }); -function run(name, { external, plugins }) { +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, @@ -40,9 +48,7 @@ function run(name, { external, plugins }) { // SSR build so the plugin's build-time transform applies (it only runs for server builds). ssr: join(__dirname, 'src', 'entry.mjs'), rollupOptions: { - // 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, + external: nodeExternals, output: { entryFileNames: 'main.mjs', format: 'es' }, }, }, @@ -50,10 +56,10 @@ function run(name, { external, plugins }) { }); } -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()] }); +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 (inlined) and plain-external + plugin-external with vite'); 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 e568f8a5afcc..58af83e04862 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-webpack/assert.mjs @@ -17,7 +17,7 @@ * @module */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,27 @@ function run(name, { withImport = false } = {}) { const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; +// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts +// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own +// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build +// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches +// ESM `from 'graphql'` and CJS `require('graphql')` alike. +const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; +const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; + +function assertBundleShape(name, { inlined }) { + const bundle = readFileSync(entryPath(name), 'utf8'); + const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); + const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); + if (inlined) { + check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); + } else { + check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); + } +} + const scenarios = { plain: run('plain'), plugin: run('plugin'), @@ -68,6 +89,11 @@ for (const [label, result] of Object.entries(scenarios)) { check(result.data?.hello === 'world', `${label}: graphql query works`); } +assertBundleShape('plain', { inlined: true }); +assertBundleShape('plugin', { inlined: true }); +assertBundleShape('plain-external', { inlined: false }); +assertBundleShape('plugin-external', { inlined: false }); + check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); check( From 8b67446fc99666b2358c3ebeedff9b2d3cc8e55e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 10:24:30 +0200 Subject: [PATCH 6/6] test(e2e): Extract shared bundler instrumentation assertions into test-utils Move the duplicated per-app assert.mjs into a single, module-parameterized `assertBundlerInstrumentation('graphql')` helper in @sentry-internal/test-utils, collapsing each app's assert to one line. Along the way: - Fix the Vite app to actually inline vs. externalize graphql via ssr.noExternal / ssr.external (rollupOptions.external is inert for Vite SSR builds), so the "inlined" variants exercise the build-time path they claim to. - Assert bundle shape (inlined vs external), scanning every emitted chunk since bundlers split the entry's dynamic import (webpack) into sibling files. - Hand the run result back through a file (SENTRY_E2E_RESULT_FILE) instead of stdout, so a piped, buffered write can't be truncated by the child's exit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-applications/node-esbuild/assert.mjs | 117 +---------- .../node-esbuild/package.json | 1 + .../node-esbuild/src/app.mjs | 10 +- .../node-esbuild/src/entry.mjs | 26 ++- .../node-rolldown/assert.mjs | 117 +---------- .../node-rolldown/package.json | 1 + .../node-rolldown/src/app.mjs | 10 +- .../node-rolldown/src/entry.mjs | 26 ++- .../test-applications/node-rollup/assert.mjs | 117 +---------- .../node-rollup/package.json | 1 + .../test-applications/node-rollup/src/app.mjs | 10 +- .../node-rollup/src/entry.mjs | 26 ++- .../test-applications/node-vite/assert.mjs | 117 +---------- .../test-applications/node-vite/package.json | 1 + .../test-applications/node-vite/src/app.mjs | 10 +- .../test-applications/node-vite/src/entry.mjs | 26 ++- .../test-applications/node-webpack/assert.mjs | 117 +---------- .../node-webpack/package.json | 1 + .../node-webpack/src/app.mjs | 10 +- .../node-webpack/src/entry.mjs | 26 ++- dev-packages/test-utils/src/build-output.ts | 17 ++ .../test-utils/src/bundler-instrumentation.ts | 197 ++++++++++++++++++ dev-packages/test-utils/src/index.ts | 4 + 23 files changed, 363 insertions(+), 625 deletions(-) create mode 100644 dev-packages/test-utils/src/bundler-instrumentation.ts 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 58af83e04862..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,112 +1,7 @@ -/** - * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that - * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: - * - * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no - * double instrumentation. - * - * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across - * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` - * line, which fails the assert rather than being silently swallowed. - * - * @module - */ -import { execFileSync } from 'node:child_process'; -import { existsSync, 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)); - -const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; - -// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). -function entryPath(name) { - const dir = join(__dirname, '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 graphql as Node -// loads it — the mechanism used for external (unbundled) dependencies. -function run(name, { withImport = false } = {}) { - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; - const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); - const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); - if (!line) { - throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); - } - return JSON.parse(line.slice('__RESULT__'.length)); -} - -const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; - -// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts -// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own -// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build -// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches -// ESM `from 'graphql'` and CJS `require('graphql')` alike. -const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; -const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; - -function assertBundleShape(name, { inlined }) { - const bundle = readFileSync(entryPath(name), 'utf8'); - const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); - const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); - if (inlined) { - check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); - } else { - check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); - } -} - -const scenarios = { - plain: run('plain'), - plugin: run('plugin'), - plainExternalImport: run('plain-external', { withImport: true }), - pluginExternalImport: run('plugin-external', { withImport: true }), -}; - -// One set of graphql spans, established by the build-time run. -const oneSet = graphqlSpanCount(scenarios.plugin); - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -for (const [label, result] of Object.entries(scenarios)) { - check(result.data?.hello === 'world', `${label}: graphql query works`); -} - -assertBundleShape('plain', { inlined: true }); -assertBundleShape('plugin', { inlined: true }); -assertBundleShape('plain-external', { inlined: false }); -assertBundleShape('plugin-external', { inlined: false }); - -check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); -check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); -check( - graphqlSpanCount(scenarios.plainExternalImport) === oneSet, - `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, -); -check( - graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, - `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, -); - -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/package.json b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json index bf6cee62844c..9067ae46f6a6 100644 --- a/dev-packages/e2e-tests/test-applications/node-esbuild/package.json +++ b/dev-packages/e2e-tests/test-applications/node-esbuild/package.json @@ -15,6 +15,7 @@ "@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" }, 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 6beffdc12191..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,10 +1,12 @@ -// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle -// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. -// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +// 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 runGraphqlQuery() { +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 f6fe03de6938..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,10 +1,12 @@ // Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs -// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are -// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a -// single machine-readable line for `assert.mjs`. +// 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'; async function main() { @@ -24,18 +26,22 @@ async function main() { spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); }); - const { runGraphqlQuery } = await import('./app.mjs'); + const { runWorkload } = await import('./app.mjs'); - let data; - await Sentry.startSpan({ name: 'graphql-work' }, async () => { - const result = await runGraphqlQuery(); - data = result.data; + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); }); await Sentry.flush(2000); - // eslint-disable-next-line no-console - console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + 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); } 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 58af83e04862..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,112 +1,7 @@ -/** - * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that - * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: - * - * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no - * double instrumentation. - * - * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across - * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` - * line, which fails the assert rather than being silently swallowed. - * - * @module - */ -import { execFileSync } from 'node:child_process'; -import { existsSync, 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)); - -const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; - -// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). -function entryPath(name) { - const dir = join(__dirname, '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 graphql as Node -// loads it — the mechanism used for external (unbundled) dependencies. -function run(name, { withImport = false } = {}) { - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; - const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); - const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); - if (!line) { - throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); - } - return JSON.parse(line.slice('__RESULT__'.length)); -} - -const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; - -// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts -// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own -// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build -// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches -// ESM `from 'graphql'` and CJS `require('graphql')` alike. -const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; -const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; - -function assertBundleShape(name, { inlined }) { - const bundle = readFileSync(entryPath(name), 'utf8'); - const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); - const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); - if (inlined) { - check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); - } else { - check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); - } -} - -const scenarios = { - plain: run('plain'), - plugin: run('plugin'), - plainExternalImport: run('plain-external', { withImport: true }), - pluginExternalImport: run('plugin-external', { withImport: true }), -}; - -// One set of graphql spans, established by the build-time run. -const oneSet = graphqlSpanCount(scenarios.plugin); - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -for (const [label, result] of Object.entries(scenarios)) { - check(result.data?.hello === 'world', `${label}: graphql query works`); -} - -assertBundleShape('plain', { inlined: true }); -assertBundleShape('plugin', { inlined: true }); -assertBundleShape('plain-external', { inlined: false }); -assertBundleShape('plugin-external', { inlined: false }); - -check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); -check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); -check( - graphqlSpanCount(scenarios.plainExternalImport) === oneSet, - `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, -); -check( - graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, - `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, -); - -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/package.json b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json index e850deda32d9..c0edf0628f9e 100644 --- a/dev-packages/e2e-tests/test-applications/node-rolldown/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rolldown/package.json @@ -15,6 +15,7 @@ "@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" }, 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 6beffdc12191..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,10 +1,12 @@ -// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle -// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. -// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +// 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 runGraphqlQuery() { +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 f6fe03de6938..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,10 +1,12 @@ // Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs -// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are -// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a -// single machine-readable line for `assert.mjs`. +// 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'; async function main() { @@ -24,18 +26,22 @@ async function main() { spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); }); - const { runGraphqlQuery } = await import('./app.mjs'); + const { runWorkload } = await import('./app.mjs'); - let data; - await Sentry.startSpan({ name: 'graphql-work' }, async () => { - const result = await runGraphqlQuery(); - data = result.data; + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); }); await Sentry.flush(2000); - // eslint-disable-next-line no-console - console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + 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); } 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 58af83e04862..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,112 +1,7 @@ -/** - * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that - * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: - * - * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no - * double instrumentation. - * - * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across - * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` - * line, which fails the assert rather than being silently swallowed. - * - * @module - */ -import { execFileSync } from 'node:child_process'; -import { existsSync, 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)); - -const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; - -// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). -function entryPath(name) { - const dir = join(__dirname, '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 graphql as Node -// loads it — the mechanism used for external (unbundled) dependencies. -function run(name, { withImport = false } = {}) { - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; - const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); - const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); - if (!line) { - throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); - } - return JSON.parse(line.slice('__RESULT__'.length)); -} - -const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; - -// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts -// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own -// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build -// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches -// ESM `from 'graphql'` and CJS `require('graphql')` alike. -const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; -const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; - -function assertBundleShape(name, { inlined }) { - const bundle = readFileSync(entryPath(name), 'utf8'); - const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); - const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); - if (inlined) { - check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); - } else { - check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); - } -} - -const scenarios = { - plain: run('plain'), - plugin: run('plugin'), - plainExternalImport: run('plain-external', { withImport: true }), - pluginExternalImport: run('plugin-external', { withImport: true }), -}; - -// One set of graphql spans, established by the build-time run. -const oneSet = graphqlSpanCount(scenarios.plugin); - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -for (const [label, result] of Object.entries(scenarios)) { - check(result.data?.hello === 'world', `${label}: graphql query works`); -} - -assertBundleShape('plain', { inlined: true }); -assertBundleShape('plugin', { inlined: true }); -assertBundleShape('plain-external', { inlined: false }); -assertBundleShape('plugin-external', { inlined: false }); - -check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); -check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); -check( - graphqlSpanCount(scenarios.plainExternalImport) === oneSet, - `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, -); -check( - graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, - `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, -); - -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/package.json b/dev-packages/e2e-tests/test-applications/node-rollup/package.json index d0571017dac6..40cc0d2cdada 100644 --- a/dev-packages/e2e-tests/test-applications/node-rollup/package.json +++ b/dev-packages/e2e-tests/test-applications/node-rollup/package.json @@ -15,6 +15,7 @@ "@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", 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 6beffdc12191..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,10 +1,12 @@ -// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle -// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. -// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +// 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 runGraphqlQuery() { +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 f6fe03de6938..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,10 +1,12 @@ // Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs -// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are -// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a -// single machine-readable line for `assert.mjs`. +// 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'; async function main() { @@ -24,18 +26,22 @@ async function main() { spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); }); - const { runGraphqlQuery } = await import('./app.mjs'); + const { runWorkload } = await import('./app.mjs'); - let data; - await Sentry.startSpan({ name: 'graphql-work' }, async () => { - const result = await runGraphqlQuery(); - data = result.data; + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); }); await Sentry.flush(2000); - // eslint-disable-next-line no-console - console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + 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); } 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 58af83e04862..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,112 +1,7 @@ -/** - * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that - * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: - * - * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no - * double instrumentation. - * - * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across - * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` - * line, which fails the assert rather than being silently swallowed. - * - * @module - */ -import { execFileSync } from 'node:child_process'; -import { existsSync, 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)); - -const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; - -// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). -function entryPath(name) { - const dir = join(__dirname, '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 graphql as Node -// loads it — the mechanism used for external (unbundled) dependencies. -function run(name, { withImport = false } = {}) { - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; - const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); - const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); - if (!line) { - throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); - } - return JSON.parse(line.slice('__RESULT__'.length)); -} - -const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; - -// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts -// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own -// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build -// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches -// ESM `from 'graphql'` and CJS `require('graphql')` alike. -const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; -const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; - -function assertBundleShape(name, { inlined }) { - const bundle = readFileSync(entryPath(name), 'utf8'); - const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); - const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); - if (inlined) { - check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); - } else { - check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); - } -} - -const scenarios = { - plain: run('plain'), - plugin: run('plugin'), - plainExternalImport: run('plain-external', { withImport: true }), - pluginExternalImport: run('plugin-external', { withImport: true }), -}; - -// One set of graphql spans, established by the build-time run. -const oneSet = graphqlSpanCount(scenarios.plugin); - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -for (const [label, result] of Object.entries(scenarios)) { - check(result.data?.hello === 'world', `${label}: graphql query works`); -} - -assertBundleShape('plain', { inlined: true }); -assertBundleShape('plugin', { inlined: true }); -assertBundleShape('plain-external', { inlined: false }); -assertBundleShape('plugin-external', { inlined: false }); - -check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); -check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); -check( - graphqlSpanCount(scenarios.plainExternalImport) === oneSet, - `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, -); -check( - graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, - `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, -); - -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/package.json b/dev-packages/e2e-tests/test-applications/node-vite/package.json index d17a47581e0a..22988b563137 100644 --- a/dev-packages/e2e-tests/test-applications/node-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/node-vite/package.json @@ -15,6 +15,7 @@ "@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" }, 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 6beffdc12191..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,10 +1,12 @@ -// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle -// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. -// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +// 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 runGraphqlQuery() { +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 f6fe03de6938..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,10 +1,12 @@ // Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs -// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are -// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a -// single machine-readable line for `assert.mjs`. +// 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'; async function main() { @@ -24,18 +26,22 @@ async function main() { spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); }); - const { runGraphqlQuery } = await import('./app.mjs'); + const { runWorkload } = await import('./app.mjs'); - let data; - await Sentry.startSpan({ name: 'graphql-work' }, async () => { - const result = await runGraphqlQuery(); - data = result.data; + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); }); await Sentry.flush(2000); - // eslint-disable-next-line no-console - console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + 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); } 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 58af83e04862..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,112 +1,7 @@ -/** - * Runs the built bundles across the build-time and runtime instrumentation paths and asserts that - * each instrumented scenario emits exactly one set of graphql spans — never zero, never double: - * - * - `plain` (inlined, no plugin, no `--import`): no graphql 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 is no - * double instrumentation. - * - * "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across - * bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__` - * line, which fails the assert rather than being silently swallowed. - * - * @module - */ -import { execFileSync } from 'node:child_process'; -import { existsSync, 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)); - -const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel'; - -// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output). -function entryPath(name) { - const dir = join(__dirname, '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 graphql as Node -// loads it — the mechanism used for external (unbundled) dependencies. -function run(name, { withImport = false } = {}) { - const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)]; - const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname }); - const line = stdout.split('\n').find(l => l.startsWith('__RESULT__')); - if (!line) { - throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`); - } - return JSON.parse(line.slice('__RESULT__'.length)); -} - -const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length; - -// Guards the build config, not just its runtime output. The span assertions below 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 graphql in every variant while the counts -// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own -// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build -// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches -// ESM `from 'graphql'` and CJS `require('graphql')` alike. -const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/; -const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema'; - -function assertBundleShape(name, { inlined }) { - const bundle = readFileSync(entryPath(name), 'utf8'); - const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle); - const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER); - if (inlined) { - check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`); - } else { - check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`); - } -} - -const scenarios = { - plain: run('plain'), - plugin: run('plugin'), - plainExternalImport: run('plain-external', { withImport: true }), - pluginExternalImport: run('plugin-external', { withImport: true }), -}; - -// One set of graphql spans, established by the build-time run. -const oneSet = graphqlSpanCount(scenarios.plugin); - -let failed = false; -function check(condition, message) { - // eslint-disable-next-line no-console - console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); - if (!condition) failed = true; -} - -for (const [label, result] of Object.entries(scenarios)) { - check(result.data?.hello === 'world', `${label}: graphql query works`); -} - -assertBundleShape('plain', { inlined: true }); -assertBundleShape('plugin', { inlined: true }); -assertBundleShape('plain-external', { inlined: false }); -assertBundleShape('plugin-external', { inlined: false }); - -check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans'); -check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans'); -check( - graphqlSpanCount(scenarios.plainExternalImport) === oneSet, - `external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`, -); -check( - graphqlSpanCount(scenarios.pluginExternalImport) === oneSet, - `external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`, -); - -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/package.json b/dev-packages/e2e-tests/test-applications/node-webpack/package.json index 1da81a3c9065..37da560ad577 100644 --- a/dev-packages/e2e-tests/test-applications/node-webpack/package.json +++ b/dev-packages/e2e-tests/test-applications/node-webpack/package.json @@ -15,6 +15,7 @@ "@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" }, 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 6beffdc12191..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,10 +1,12 @@ -// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle -// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it. -// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`). +// 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 runGraphqlQuery() { +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 f6fe03de6938..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,10 +1,12 @@ // Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs -// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are -// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a -// single machine-readable line for `assert.mjs`. +// 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'; async function main() { @@ -24,18 +26,22 @@ async function main() { spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] }); }); - const { runGraphqlQuery } = await import('./app.mjs'); + const { runWorkload } = await import('./app.mjs'); - let data; - await Sentry.startSpan({ name: 'graphql-work' }, async () => { - const result = await runGraphqlQuery(); - data = result.data; + let result; + await Sentry.startSpan({ name: 'workload' }, async () => { + result = await runWorkload(); }); await Sentry.flush(2000); - // eslint-disable-next-line no-console - console.log(`__RESULT__${JSON.stringify({ data, spans })}`); + 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); } 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';