Skip to content

Commit 04d550b

Browse files
mydeaclaude
andcommitted
test(e2e): Run bundled graphql through node bundler apps and assert instrumentation
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) <noreply@anthropic.com>
1 parent 8688a44 commit 04d550b

25 files changed

Lines changed: 503 additions & 239 deletions

File tree

‎dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs‎

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
11
/**
2-
* Asserts that `sentryEsbuildPlugin` performs build-time instrumentation: its code transform injects
3-
* the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not.
2+
* Runs both built bundles and asserts that build-time instrumentation actually fires at runtime:
3+
* - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the
4+
* bundle at boot),
5+
* - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`,
6+
* - `plain` build: they do not (negative control — no plugin, runtime hook disabled).
7+
*
8+
* A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert
9+
* rather than being silently swallowed.
410
*
511
* @module
612
*/
7-
import { readdirSync, readFileSync } from 'node:fs';
13+
import { execFileSync } from 'node:child_process';
14+
import { existsSync } from 'node:fs';
815
import { dirname, join } from 'node:path';
916
import { fileURLToPath } from 'node:url';
1017

1118
const __dirname = dirname(fileURLToPath(import.meta.url));
1219

13-
// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform
14-
// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`).
15-
// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build
16-
// apart from a `plain` one. We match against a whitespace-stripped bundle because some bundlers
17-
// (e.g. Rolldown) pretty-print the injected banner rather than emitting it verbatim.
18-
const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||[]';
19-
20-
function bundleText(name) {
21-
const files = [];
22-
const walk = dir => {
23-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
24-
const full = join(dir, entry.name);
25-
if (entry.isDirectory()) {
26-
walk(full);
27-
} else {
28-
files.push(full);
29-
}
30-
}
31-
};
32-
walk(join(__dirname, 'dist', name));
33-
return files
34-
.map(f => readFileSync(f, 'utf8'))
35-
.join('\n')
36-
.replace(/\s+/g, '');
20+
const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel';
21+
22+
// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output).
23+
function entryPath(name) {
24+
const dir = join(__dirname, 'dist', name);
25+
const entry = ['main.mjs', 'main.cjs', 'main.js'].map(f => join(dir, f)).find(existsSync);
26+
if (!entry) throw new Error(`no built entry (main.mjs/.cjs/.js) found in ${dir}`);
27+
return entry;
28+
}
29+
30+
function runBundle(name) {
31+
const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' });
32+
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
33+
if (!line) {
34+
throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`);
35+
}
36+
return JSON.parse(line.slice('__RESULT__'.length));
3737
}
3838

3939
let failed = false;
@@ -43,13 +43,20 @@ function check(condition, message) {
4343
if (!condition) failed = true;
4444
}
4545

46-
const plain = bundleText('plain');
47-
const plugin = bundleText('plugin');
46+
const plain = runBundle('plain');
47+
const plugin = runBundle('plugin');
4848

49-
check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
49+
const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN);
50+
51+
check(plain.data?.hello === 'world', 'plain build: graphql query works');
52+
check(plugin.data?.hello === 'world', 'plugin build: graphql query works');
53+
check(
54+
!hasGraphqlOrigin(plain),
55+
'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)',
56+
);
5057
check(
51-
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
52-
'sentryEsbuildPlugin runs build-time instrumentation (injects the orchestrion banner)',
58+
hasGraphqlOrigin(plugin),
59+
'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)',
5360
);
5461

5562
if (failed) {

‎dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
// Bundles the entrypoint with esbuild twice:
2-
// - `plain`: no Sentry plugin.
3-
// - `plugin`: with `sentryEsbuildPlugin` (build-time instrumentation).
4-
// Only the `plugin` build runs the orchestrion code transform, which prepends the "bundler ran"
5-
// banner to the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would
6-
// rename them); assert.mjs matches it whitespace-insensitively.
1+
// Bundles the entrypoint with esbuild twice, each a directly-runnable bundle with `graphql` inlined
2+
// (only node builtins stay external):
3+
// - `plain`: no Sentry plugin -> graphql is not instrumented.
4+
// - `plugin`: with `sentryEsbuildPlugin` -> the orchestrion transform instruments graphql at build
5+
// time.
6+
// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear.
7+
// Kept unminified so the injected snippet keeps its identifiers.
8+
import { rmSync } from 'node:fs';
79
import { dirname, join } from 'node:path';
810
import { fileURLToPath } from 'node:url';
911
import { build } from 'esbuild';
1012
import { sentryEsbuildPlugin } from '@sentry/node/esbuild';
1113

1214
const __dirname = dirname(fileURLToPath(import.meta.url));
1315

16+
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
17+
1418
function run(name, plugins) {
1519
return build({
1620
entryPoints: [join(__dirname, 'src', 'entry.mjs')],
17-
outdir: join(__dirname, 'dist', name),
21+
outfile: join(__dirname, 'dist', name, 'main.cjs'),
1822
bundle: true,
1923
platform: 'node',
20-
format: 'esm',
24+
format: 'cjs',
2125
minify: false,
2226
logLevel: 'silent',
2327
plugins,

‎dev-packages/e2e-tests/test-applications/node-esbuild/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "node-esbuild",
3-
"description": "ensure the Sentry esbuild plugin performs build-time instrumentation",
3+
"description": "ensure the Sentry esbuild plugin build-time instruments a bundled graphql at runtime",
44
"version": "1.0.0",
55
"private": true,
66
"type": "module",
@@ -15,6 +15,7 @@
1515
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
1616
},
1717
"devDependencies": {
18+
"graphql": "16.9.0",
1819
"esbuild": "0.28.2"
1920
},
2021
"volta": {
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
1-
// eslint-disable-next-line no-console
2-
console.log('this is the application');
1+
// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle
2+
// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it.
3+
// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`).
4+
import { buildSchema, graphql } from 'graphql';
5+
6+
const schema = buildSchema('type Query { hello: String }');
7+
8+
export async function runGraphqlQuery() {
9+
return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } });
10+
}
Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,42 @@
1+
// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs
2+
// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are
3+
// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a
4+
// single machine-readable line for `assert.mjs`.
5+
//
6+
// The body is an async function rather than top-level await so the same source bundles to both ESM
7+
// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await).
18
import * as Sentry from '@sentry/node';
29

3-
Sentry.init({
4-
traceLifecycle: 'static',
5-
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6-
tracesSampleRate: 1,
7-
});
10+
async function main() {
11+
Sentry.init({
12+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
13+
tracesSampleRate: 1,
14+
// Isolate the build-time path: with the runtime hook off, the bundler plugin is the only possible
15+
// injector, so a `plain` (no-plugin) build is a true negative.
16+
enableRuntimeChannelInjection: false,
17+
// Hermetic — never hit the network.
18+
transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }),
19+
});
820

9-
await import('./app.mjs');
21+
const spans = [];
22+
Sentry.getClient()?.on('spanEnd', span => {
23+
const json = Sentry.spanToJSON(span);
24+
spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] });
25+
});
26+
27+
const { runGraphqlQuery } = await import('./app.mjs');
28+
29+
let data;
30+
await Sentry.startSpan({ name: 'graphql-work' }, async () => {
31+
const result = await runGraphqlQuery();
32+
data = result.data;
33+
});
34+
35+
await Sentry.flush(2000);
36+
37+
// eslint-disable-next-line no-console
38+
console.log(`__RESULT__${JSON.stringify({ data, spans })}`);
39+
process.exit(0);
40+
}
41+
42+
void main();

‎dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs‎

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
11
/**
2-
* Asserts that `sentryRollupPlugin` performs build-time instrumentation when bundling with Rolldown: its code transform injects
3-
* the orchestrion "bundler ran" banner into the entry chunk. A plain build (no plugin) does not.
2+
* Runs both built bundles and asserts that build-time instrumentation actually fires at runtime:
3+
* - both builds: the graphql query returns data (the SDK/plugin doesn't break the app or crash the
4+
* bundle at boot),
5+
* - `plugin` build: graphql auto-spans appear with origin `auto.graphql.diagnostic_channel`,
6+
* - `plain` build: they do not (negative control — no plugin, runtime hook disabled).
7+
*
8+
* A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert
9+
* rather than being silently swallowed.
410
*
511
* @module
612
*/
7-
import { readdirSync, readFileSync } from 'node:fs';
13+
import { execFileSync } from 'node:child_process';
14+
import { existsSync } from 'node:fs';
815
import { dirname, join } from 'node:path';
916
import { fileURLToPath } from 'node:url';
1017

1118
const __dirname = dirname(fileURLToPath(import.meta.url));
1219

13-
// A distinctive slice of the orchestrion banner that the bundler plugin's build-time code transform
14-
// prepends to the entry chunk (see `ORCHESTRION_BUNDLER_MARKER_BANNER` in `@sentry/server-utils`).
15-
// It is emitted only when the plugin's build-time instrumentation runs, so it tells a `plugin` build
16-
// apart from a `plain` one. We match against a whitespace-stripped bundle because some bundlers
17-
// (e.g. Rolldown) pretty-print the injected banner rather than emitting it verbatim.
18-
const BUILD_TIME_TRANSFORM_MARKER = 'g.bundler=g.bundler||[]';
19-
20-
function bundleText(name) {
21-
const files = [];
22-
const walk = dir => {
23-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
24-
const full = join(dir, entry.name);
25-
if (entry.isDirectory()) {
26-
walk(full);
27-
} else {
28-
files.push(full);
29-
}
30-
}
31-
};
32-
walk(join(__dirname, 'dist', name));
33-
return files
34-
.map(f => readFileSync(f, 'utf8'))
35-
.join('\n')
36-
.replace(/\s+/g, '');
20+
const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel';
21+
22+
// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output).
23+
function entryPath(name) {
24+
const dir = join(__dirname, 'dist', name);
25+
const entry = ['main.mjs', 'main.cjs', 'main.js'].map(f => join(dir, f)).find(existsSync);
26+
if (!entry) throw new Error(`no built entry (main.mjs/.cjs/.js) found in ${dir}`);
27+
return entry;
28+
}
29+
30+
function runBundle(name) {
31+
const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' });
32+
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
33+
if (!line) {
34+
throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`);
35+
}
36+
return JSON.parse(line.slice('__RESULT__'.length));
3737
}
3838

3939
let failed = false;
@@ -43,13 +43,20 @@ function check(condition, message) {
4343
if (!condition) failed = true;
4444
}
4545

46-
const plain = bundleText('plain');
47-
const plugin = bundleText('plugin');
46+
const plain = runBundle('plain');
47+
const plugin = runBundle('plugin');
4848

49-
check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
49+
const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN);
50+
51+
check(plain.data?.hello === 'world', 'plain build: graphql query works');
52+
check(plugin.data?.hello === 'world', 'plugin build: graphql query works');
53+
check(
54+
!hasGraphqlOrigin(plain),
55+
'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)',
56+
);
5057
check(
51-
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
52-
'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)',
58+
hasGraphqlOrigin(plugin),
59+
'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)',
5360
);
5461

5562
if (failed) {

‎dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
// Bundles the entrypoint with Rolldown twice:
2-
// - `plain`: no Sentry plugin.
3-
// - `plugin`: with `sentryRollupPlugin` (build-time instrumentation).
4-
// Only the `plugin` build runs the orchestrion code transform, which prepends the "bundler ran"
5-
// banner to the entry chunk. Kept unminified so the banner keeps its identifiers (a minifier would
6-
// rename them); assert.mjs matches it whitespace-insensitively.
1+
// Bundles the entrypoint with Rolldown twice, each a directly-runnable ESM bundle with `graphql`
2+
// inlined (only node builtins stay external):
3+
// - `plain`: no Sentry plugin -> graphql is not instrumented.
4+
// - `plugin`: with `sentryRollupPlugin` -> the orchestrion transform instruments graphql at build
5+
// time.
76
// Rolldown is Rollup API-compatible, so it consumes the same `@sentry/node/rollup` plugin; it also
87
// resolves node modules and CommonJS natively, so no extra resolve/commonjs plugins are needed.
8+
// `assert.mjs` runs both bundles and checks the graphql query works and which auto-spans appear.
9+
// Kept unminified so the injected snippet keeps its identifiers.
10+
import { rmSync } from 'node:fs';
911
import { builtinModules } from 'node:module';
1012
import { dirname, join } from 'node:path';
1113
import { fileURLToPath } from 'node:url';
@@ -15,6 +17,8 @@ import { sentryRollupPlugin } from '@sentry/node/rollup';
1517
const __dirname = dirname(fileURLToPath(import.meta.url));
1618
const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)];
1719

20+
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
21+
1822
async function run(name, extra) {
1923
const bundle = await rolldown({
2024
input: join(__dirname, 'src', 'entry.mjs'),

‎dev-packages/e2e-tests/test-applications/node-rolldown/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "node-rolldown",
3-
"description": "ensure the Sentry rollup plugin performs build-time instrumentation when bundling with rolldown",
3+
"description": "ensure the Sentry rollup plugin build-time instruments a bundled graphql when bundling with rolldown at runtime",
44
"version": "1.0.0",
55
"private": true,
66
"type": "module",
@@ -15,6 +15,7 @@
1515
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
1616
},
1717
"devDependencies": {
18+
"graphql": "16.9.0",
1819
"rolldown": "1.2.5"
1920
},
2021
"volta": {
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
1-
// eslint-disable-next-line no-console
2-
console.log('this is the application');
1+
// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle
2+
// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it.
3+
// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`).
4+
import { buildSchema, graphql } from 'graphql';
5+
6+
const schema = buildSchema('type Query { hello: String }');
7+
8+
export async function runGraphqlQuery() {
9+
return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } });
10+
}

0 commit comments

Comments
 (0)