From e25e5838a0d988a4fbd8e763102f8c0a1aec4bb7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 10:19:23 +0200 Subject: [PATCH 1/2] 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 07f694abb4355932c1c3e0833cbd8ae21a5a3479 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 10:43:07 +0200 Subject: [PATCH 2/2] 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()] });