Skip to content

Commit e5bf0b9

Browse files
mydeaclaude
andcommitted
test(e2e): Cover the runtime --import path and assert exactly one span set
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) <noreply@anthropic.com>
1 parent 0e12cc0 commit e5bf0b9

10 files changed

Lines changed: 329 additions & 208 deletions

File tree

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

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
/**
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).
2+
* Runs the built bundles across the build-time and runtime instrumentation paths and asserts that
3+
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
74
*
8-
* A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert
9-
* rather than being silently swallowed.
5+
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
6+
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
7+
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
8+
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
9+
* an external module, so the runtime hook
10+
* is the sole injector and there is no
11+
* double instrumentation.
12+
*
13+
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
14+
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
15+
* line, which fails the assert rather than being silently swallowed.
1016
*
1117
* @module
1218
*/
@@ -27,36 +33,50 @@ function entryPath(name) {
2733
return entry;
2834
}
2935

30-
function runBundle(name) {
31-
const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' });
36+
// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node
37+
// loads it — the mechanism used for external (unbundled) dependencies.
38+
function run(name, { withImport = false } = {}) {
39+
const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)];
40+
const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname });
3241
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
3342
if (!line) {
34-
throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`);
43+
throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`);
3544
}
3645
return JSON.parse(line.slice('__RESULT__'.length));
3746
}
3847

48+
const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;
49+
50+
const scenarios = {
51+
plain: run('plain'),
52+
plugin: run('plugin'),
53+
plainExternalImport: run('plain-external', { withImport: true }),
54+
pluginExternalImport: run('plugin-external', { withImport: true }),
55+
};
56+
57+
// One set of graphql spans, established by the build-time run.
58+
const oneSet = graphqlSpanCount(scenarios.plugin);
59+
3960
let failed = false;
4061
function check(condition, message) {
4162
// eslint-disable-next-line no-console
4263
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
4364
if (!condition) failed = true;
4465
}
4566

46-
const plain = runBundle('plain');
47-
const plugin = runBundle('plugin');
48-
49-
const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN);
67+
for (const [label, result] of Object.entries(scenarios)) {
68+
check(result.data?.hello === 'world', `${label}: graphql query works`);
69+
}
5070

51-
check(plain.data?.hello === 'world', 'plain build: graphql query works');
52-
check(plugin.data?.hello === 'world', 'plugin build: graphql query works');
71+
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
72+
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
5373
check(
54-
!hasGraphqlOrigin(plain),
55-
'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)',
74+
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
75+
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
5676
);
5777
check(
58-
hasGraphqlOrigin(plugin),
59-
'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)',
78+
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
79+
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
6080
);
6181

6282
if (failed) {
Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
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.
1+
// Bundles the entrypoint with esbuild four ways, each a directly-runnable CJS bundle:
2+
// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryEsbuildPlugin`)
3+
// build-time instruments it. Run without `--import`.
4+
// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can
5+
// intercept it at load time. Run with `--import`.
6+
// esbuild emits CJS (not ESM): its ESM output can't perform the CJS `require('node:async_hooks')` that
7+
// `@sentry/server-utils` does once inlined, and CJS is the normal esbuild node target. `assert.mjs`
8+
// runs all four and checks the query works and that exactly one set of graphql spans is emitted in
9+
// each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers.
810
import { rmSync } from 'node:fs';
911
import { dirname, join } from 'node:path';
1012
import { fileURLToPath } from 'node:url';
@@ -15,31 +17,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1517

1618
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
1719

18-
function run(name, plugins) {
20+
// No auth/release/telemetry — we only care about the build-time transforms and defines.
21+
const makeSentryPlugin = () =>
22+
sentryEsbuildPlugin({
23+
telemetry: false,
24+
sourcemaps: { disable: true },
25+
release: { create: false, finalize: false, inject: false },
26+
});
27+
28+
function run(name, { external, plugins }) {
1929
return build({
2030
entryPoints: [join(__dirname, 'src', 'entry.mjs')],
2131
outfile: join(__dirname, 'dist', name, 'main.cjs'),
2232
bundle: true,
2333
platform: 'node',
2434
format: 'cjs',
35+
// The `*-external` variants keep graphql out of the bundle, so it is resolved from node_modules at
36+
// runtime and the `--import` hook can transform it as it loads.
37+
external: external ? ['graphql'] : [],
2538
minify: false,
2639
logLevel: 'silent',
2740
plugins,
2841
});
2942
}
3043

31-
await run('plain', []);
32-
await run(
33-
'plugin',
34-
// No auth/release/telemetry — we only care about the build-time transforms and defines.
35-
[
36-
sentryEsbuildPlugin({
37-
telemetry: false,
38-
sourcemaps: { disable: true },
39-
release: { create: false, finalize: false, inject: false },
40-
}),
41-
],
42-
);
44+
await run('plain', { external: false, plugins: [] });
45+
await run('plugin', { external: false, plugins: [makeSentryPlugin()] });
46+
await run('plain-external', { external: true, plugins: [] });
47+
await run('plugin-external', { external: true, plugins: [makeSentryPlugin()] });
4348

4449
// eslint-disable-next-line no-console
45-
console.log('built plain + plugin with esbuild');
50+
console.log('built plain + plugin (inlined) and plain-external + plugin-external with esbuild');

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

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
/**
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).
2+
* Runs the built bundles across the build-time and runtime instrumentation paths and asserts that
3+
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
74
*
8-
* A boot crash surfaces as a non-zero child exit / missing `__RESULT__` line, which fails the assert
9-
* rather than being silently swallowed.
5+
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
6+
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
7+
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
8+
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
9+
* an external module, so the runtime hook
10+
* is the sole injector and there is no
11+
* double instrumentation.
12+
*
13+
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
14+
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
15+
* line, which fails the assert rather than being silently swallowed.
1016
*
1117
* @module
1218
*/
@@ -27,36 +33,50 @@ function entryPath(name) {
2733
return entry;
2834
}
2935

30-
function runBundle(name) {
31-
const stdout = execFileSync(process.execPath, [entryPath(name)], { encoding: 'utf8' });
36+
// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node
37+
// loads it — the mechanism used for external (unbundled) dependencies.
38+
function run(name, { withImport = false } = {}) {
39+
const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)];
40+
const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname });
3241
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
3342
if (!line) {
34-
throw new Error(`${name} build did not print a __RESULT__ line. Output:\n${stdout}`);
43+
throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`);
3544
}
3645
return JSON.parse(line.slice('__RESULT__'.length));
3746
}
3847

48+
const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;
49+
50+
const scenarios = {
51+
plain: run('plain'),
52+
plugin: run('plugin'),
53+
plainExternalImport: run('plain-external', { withImport: true }),
54+
pluginExternalImport: run('plugin-external', { withImport: true }),
55+
};
56+
57+
// One set of graphql spans, established by the build-time run.
58+
const oneSet = graphqlSpanCount(scenarios.plugin);
59+
3960
let failed = false;
4061
function check(condition, message) {
4162
// eslint-disable-next-line no-console
4263
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
4364
if (!condition) failed = true;
4465
}
4566

46-
const plain = runBundle('plain');
47-
const plugin = runBundle('plugin');
48-
49-
const hasGraphqlOrigin = result => result.spans.some(s => s.origin === GRAPHQL_ORIGIN);
67+
for (const [label, result] of Object.entries(scenarios)) {
68+
check(result.data?.hello === 'world', `${label}: graphql query works`);
69+
}
5070

51-
check(plain.data?.hello === 'world', 'plain build: graphql query works');
52-
check(plugin.data?.hello === 'world', 'plugin build: graphql query works');
71+
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
72+
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
5373
check(
54-
!hasGraphqlOrigin(plain),
55-
'plain build (no plugin) does not auto-instrument graphql (no auto.graphql.diagnostic_channel span)',
74+
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
75+
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
5676
);
5777
check(
58-
hasGraphqlOrigin(plugin),
59-
'Sentry bundler plugin auto-instruments graphql at build time (emits auto.graphql.diagnostic_channel span)',
78+
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
79+
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
6080
);
6181

6282
if (failed) {
Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
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.
1+
// Bundles the entrypoint with Rolldown four ways, each a directly-runnable ESM bundle:
2+
// - `plain` / `plugin`: graphql inlined. Only `plugin` (with `sentryRollupPlugin`)
3+
// build-time instruments it. Run without `--import`.
4+
// - `plain-external` / `plugin-external`: graphql kept external so the runtime `--import` hook can
5+
// intercept it at load time. Run with `--import`.
66
// Rolldown is Rollup API-compatible, so it consumes the same `@sentry/node/rollup` plugin; it also
77
// 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.
8+
// `assert.mjs` runs all four and checks the query works and that exactly one set of graphql spans is
9+
// emitted in each instrumented scenario. Kept unminified so the injected snippet keeps its identifiers.
1010
import { rmSync } from 'node:fs';
1111
import { builtinModules } from 'node:module';
1212
import { dirname, join } from 'node:path';
@@ -15,32 +15,36 @@ import { rolldown } from 'rolldown';
1515
import { sentryRollupPlugin } from '@sentry/node/rollup';
1616

1717
const __dirname = dirname(fileURLToPath(import.meta.url));
18-
const external = [...builtinModules, ...builtinModules.map(m => `node:${m}`)];
18+
const nodeExternals = [...builtinModules, ...builtinModules.map(m => `node:${m}`)];
1919

2020
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
2121

22-
async function run(name, extra) {
22+
// No auth/release/telemetry — we only care about the build-time transforms and defines.
23+
const makeSentryPlugin = () =>
24+
// `sentryRollupPlugin` returns an array of Rollup plugins.
25+
sentryRollupPlugin({
26+
telemetry: false,
27+
sourcemaps: { disable: true },
28+
release: { create: false, finalize: false, inject: false },
29+
});
30+
31+
async function run(name, { external, plugins }) {
2332
const bundle = await rolldown({
2433
input: join(__dirname, 'src', 'entry.mjs'),
25-
external,
26-
plugins: [...extra],
34+
// The `*-external` variants keep graphql out of the bundle, so it is resolved from node_modules at
35+
// runtime and the `--import` hook can transform it as it loads.
36+
external: external ? [...nodeExternals, 'graphql'] : nodeExternals,
37+
plugins: [...plugins],
2738
onwarn: () => {},
2839
});
2940
await bundle.write({ dir: join(__dirname, 'dist', name), format: 'es', entryFileNames: 'main.mjs' });
3041
await bundle.close();
3142
}
3243

33-
await run('plain', []);
34-
await run(
35-
'plugin',
36-
// `sentryRollupPlugin` returns an array of Rollup plugins. No auth/release/telemetry — we only care
37-
// about the build-time transforms and defines.
38-
sentryRollupPlugin({
39-
telemetry: false,
40-
sourcemaps: { disable: true },
41-
release: { create: false, finalize: false, inject: false },
42-
}),
43-
);
44+
await run('plain', { external: false, plugins: [] });
45+
await run('plugin', { external: false, plugins: makeSentryPlugin() });
46+
await run('plain-external', { external: true, plugins: [] });
47+
await run('plugin-external', { external: true, plugins: makeSentryPlugin() });
4448

4549
// eslint-disable-next-line no-console
46-
console.log('built plain + plugin with rolldown');
50+
console.log('built plain + plugin (inlined) and plain-external + plugin-external with rolldown');

0 commit comments

Comments
 (0)