Skip to content

test(e2e): Verify bundler plugins instrument a bundled graphql at runtime - #23670

Merged
mydea merged 6 commits into
fn/graphql-esm-instrumentfrom
fn/bundler-graphql-runtime
Aug 28, 2026
Merged

test(e2e): Verify bundler plugins instrument a bundled graphql at runtime#23670
mydea merged 6 commits into
fn/graphql-esm-instrumentfrom
fn/bundler-graphql-runtime

Conversation

@mydea

@mydea mydea commented Aug 27, 2026

Copy link
Copy Markdown
Member

Turns the five node bundler apps (node-webpack, node-vite, node-rollup, node-rolldown, node-esbuild) from a static banner-grep into a real runtime test, in the spirit of orchestrion-treeshake-repro: actually run the bundled service against a real graphql package and verify what gets injected — across both the build-time (bundler plugin) and runtime (--import hook) instrumentation paths.

Each app builds the same graphql workload four ways and runs each bundle:

  • plain — graphql inlined, no plugin, no --import: no graphql spans (negative control),
  • plugin — graphql inlined, Sentry bundler plugin, no --import: one set of spans, via build-time injection,
  • plain-external — graphql external, --import: one set, via the runtime hook,
  • plugin-external — graphql external, plugin, --import: one set — the plugin can't touch an external module, so the runtime hook is the sole injector and there's no double instrumentation.

"One set" is defined relative to the build-time run, so the count stays correct across bundlers and graphql versions. This guards the fix beneath it (#23669) and catches the whole class of "bundle looks instrumented but emits no/double spans" regressions the static grep couldn't.

Shared assertion helper

The per-app assert.mjs was ~86 identical lines copied five times. It now lives once in @sentry-internal/test-utils as assertBundlerInstrumentation('graphql'), and each app's assert.mjs is a single call. The helper is parameterized by a small fixture descriptor ({ moduleName, origin, sourceMarker, assertResult }) rather than hardcoded to graphql, so a future library is one registry entry plus a src/app.mjs workload — everything else (the four-variant matrix, the "one set" logic, the shape checks) is generic. entry.mjs is likewise library-agnostic: it runs a conventional runWorkload() and ships whatever it returns back as result.

Decisions

  • Control inlining with Vite's SSR knob, not rollupOptions.external. A Vite SSR build externalizes deps by default, so rollupOptions.external never governed whether graphql was bundled — the "inlined" variants were silently shipping an external graphql and not exercising the build-time path at all. The variants now set ssr.noExternal / ssr.external explicitly (the latter also wins over the plugin's own force-bundle, leaving graphql for the runtime hook).
  • Assert the bundle shape, not just the span counts. The counts can come out right by accident even when the externalization knob is a no-op, so each variant is also checked to actually be inlined (carries graphql's own source, no bare graphql import) or external (keeps the bare import, no inlined source). This scans every emitted chunk in the output dir, because bundlers split the entry's await import('./app.mjs') into a sibling chunk (webpack) where the marker lands rather than in main.*.
  • Hand the result back through a file, not stdout. console.log(...) + process.exit() can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains). The entry writeFileSyncs its result to SENTRY_E2E_RESULT_FILE and the helper reads it back; a boot crash or a missing file surfaces as an explicit failure instead of being swallowed.
  • Isolate the build-time path. The entry sets enableRuntimeChannelInjection: false and the non---import bundles run with a plain node, so the bundler plugin is the only possible injector — making the plain build a true negative rather than something the runtime hook could rescue.
  • Capture via the spanEnd hook (+ spanToJSON) rather than beforeSendSpan, so collection is independent of transport and trace lifecycle. Transport is a no-op and the DSN fake — nothing hits the network.
  • Entry is an async function, not top-level await, so the same source bundles to both ESM and esbuild's CJS node output.
  • esbuild emits CJS, the other four emit ESM. esbuild's ESM output can't perform the CJS require('node:async_hooks') that @sentry/server-utils does once inlined (an esbuild CJS-in-ESM interop limit, the "separate bug" the repro noted); CJS output is the normal esbuild node target and sidesteps it. This also means esbuild resolves graphql's CJS build, so the suite covers both the .js and .mjs orchestrion paths.
  • Dropped the banner-grep assertion: the runtime span check is a strictly stronger proof that build-time injection ran, and the banner's new Set() formatting varies by bundler.

@mydea
mydea force-pushed the fn/bundler-graphql-runtime branch from 28dc52a to 36b76fb Compare August 27, 2026 08:26
@mydea
mydea force-pushed the fn/bundler-graphql-runtime branch from 36b76fb to 04d550b Compare August 27, 2026 08:26
@mydea
mydea force-pushed the fn/bundler-graphql-runtime branch from 04d550b to 0e12cc0 Compare August 27, 2026 08:29

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0e12cc0. Configure here.

Comment thread dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs
Comment thread dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.57 kB -0.02% -4 B 🔽
@sentry/browser - with treeshaking flags 26.92 kB -0.04% -9 B 🔽
@sentry/browser - with treeshaking flags tracing without tracing 26.82 kB -0.03% -7 B 🔽
@sentry/browser (incl. Tracing) 48.63 kB -0.01% -2 B 🔽
@sentry/browser (incl. Tracing + Span Streaming) 48.65 kB -0.01% -2 B 🔽
@sentry/browser (incl. Tracing, Profiling) 51.56 kB -0.01% -5 B 🔽
@sentry/browser (incl. Tracing, Replay) 88.11 kB -0.01% -2 B 🔽
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 77.51 kB -0.02% -8 B 🔽
@sentry/browser (incl. Tracing, Replay with Canvas) 92.82 kB -0.01% -2 B 🔽
@sentry/browser (incl. Tracing, Replay, Feedback) 105.51 kB -0.01% -1 B 🔽
@sentry/browser (incl. Feedback) 45.79 kB -0.02% -5 B 🔽
@sentry/browser (incl. sendFeedback) 33.35 kB -0.02% -6 B 🔽
@sentry/browser (incl. FeedbackAsync) 38.46 kB -0.01% -3 B 🔽
@sentry/browser (incl. Metrics) 29.51 kB -0.02% -3 B 🔽
@sentry/browser (incl. Logs) 29.8 kB -0.01% -1 B 🔽
@sentry/browser (incl. Metrics & Logs) 30.43 kB -0.01% -1 B 🔽
@sentry/react 30.31 kB -0.02% -5 B 🔽
@sentry/react (incl. Tracing) 50.84 kB -0.02% -6 B 🔽
@sentry/vue 35.69 kB - -
@sentry/vue (incl. Tracing) 50.88 kB -0.01% -4 B 🔽
@sentry/svelte 28.59 kB -0.01% -2 B 🔽
CDN Bundle 30.36 kB +0.05% +15 B 🔺
CDN Bundle (incl. Tracing) 49.12 kB -0.01% -2 B 🔽
CDN Bundle (incl. Logs, Metrics) 32.56 kB +0.03% +7 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 51.01 kB +0.01% +1 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 73 kB -0.03% -18 B 🔽
CDN Bundle (incl. Tracing, Replay) 86.62 kB -0.01% -6 B 🔽
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 88.52 kB -0.01% -2 B 🔽
CDN Bundle (incl. Tracing, Replay, Feedback) 92.4 kB -0.01% -5 B 🔽
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 94.33 kB -0.01% -4 B 🔽
CDN Bundle - uncompressed 89.97 kB +0.02% +15 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 146.82 kB +0.01% +1 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 96.26 kB +0.02% +15 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 152.51 kB +0.01% +1 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 225.42 kB +0.01% +15 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 266.32 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 272 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 280.01 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 285.68 kB +0.01% +1 B 🔺
@sentry/nextjs (client) 53.41 kB -0.01% -4 B 🔽
@sentry/sveltekit (client) 49.08 kB -0.01% -4 B 🔽
@sentry/core/server 65.15 kB - -
@sentry/core/browser 52.37 kB +0.89% +460 B 🔺
@sentry/node 122.71 kB +0.03% +33 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 85.23 kB +0.03% +19 B 🔺
@sentry/node - without tracing 87.74 kB +0.05% +39 B 🔺
@sentry/node - without channel injection 102.38 kB +0.04% +33 B 🔺
@sentry/aws-serverless 95.9 kB +0.05% +44 B 🔺
@sentry/cloudflare (withSentry) - minified 199.61 kB -0.04% -79 B 🔽
@sentry/cloudflare (withSentry) 495.94 kB -0.06% -249 B 🔽

View base workflow run

@mydea
mydea force-pushed the fn/bundler-graphql-runtime branch from e5bf0b9 to 07f694a Compare August 27, 2026 09:36
@mydea
mydea marked this pull request as ready for review August 27, 2026 11:37
@mydea
mydea requested review from Lms24, andreiborza and isaacs August 27, 2026 13:37

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test can be tightened a bit to more definitively cover what it claims to, but it's certainly a improvement in any event.

output: { entryFileNames: 'main.mjs', format: 'es' },
},
},
plugins,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The graphql module would be external by default, so we need to explicitly tell it not to be, or else this test isn't testing what it claims.

Suggested change
plugins,
// the graphql module is external by default, we need to tell vite
// to make it internal explicitly, so that this test does what it claims.
ssr: external ? {} : { noExternal: ['graphql'] },
plugins,

.map(f => readFileSync(f, 'utf8'))
.join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, '');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The banner test was kind of irrelevant, and the whitespace stripping a bit brittle, so removing this is good. However, now nothing asserts the shape of the build anymore, which is presumably how graphql was not actually being inlined as expected.

This patch would add a check that it's being inlined or kept external as expected: https://gist.github.com/isaacs/2d9f123a663771811c04178f1089b7f9

Comment on lines +38 to +39
console.log(`__RESULT__${JSON.stringify({ data, spans })}`);
process.exit(0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can go flakey if stdout is a pipe, if the exit lands before write flushes, resulting in EPIPE.

Suggestion:

// up at the top of the file
import { writeSync } from 'node:fs';
Suggested change
console.log(`__RESULT__${JSON.stringify({ data, spans })}`);
process.exit(0);
writeSync(1, `__RESULT__${JSON.stringify({ data, spans })}`);
process.exit(0);

Comment thread dev-packages/e2e-tests/test-applications/node-rolldown/build.mjs
process.exit(0);
}

void main();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The test harness uses execFileSync without stdio: 'pipe', causing the actual error details from a crashed child process to be lost, hindering debugging.
Severity: MEDIUM

Suggested Fix

Add the stdio: 'pipe' option to the execFileSync call in bundler-instrumentation.ts. This will ensure that the standard output and standard error streams of the child process are captured and made available on the stdout and stderr properties of the error object in the catch block.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: dev-packages/e2e-tests/test-applications/node-rollup/src/entry.mjs#L48

Potential issue: The test harness in `bundler-instrumentation.ts` invokes a child
process using `execFileSync` but fails to configure it to capture the child's output
streams. The `catch` block attempts to read `err.stdout` and `err.stderr` from the error
object, but these properties will be `undefined` because the `stdio: 'pipe'` option is
missing. If the child process crashes due to an unhandled promise rejection (e.g., a
file write error), the test will fail with a generic message, and the actual error
details from the child process will be lost. This silent swallowing of the root cause
makes debugging test failures significantly more difficult.

Also affects:

  • dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs:48~48
  • dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs:48~48
  • dev-packages/e2e-tests/test-applications/node-vite/src/entry.mjs:48~48
  • dev-packages/e2e-tests/test-applications/node-webpack/src/entry.mjs:48~48

Did we get this right? 👍 / 👎 to inform future reviews.

mydea and others added 6 commits August 28, 2026 11:01
Move the "does this app need Playwright" decision into the install-playwright
action: it resolves @playwright/test from the app and, when the package isn't
there, emits an empty version and skips the browser install (and its cache
steps) instead of failing on the missing require. Apps that assert via a plain
node script (e.g. the node bundler apps) no longer pay for a Playwright install
they don't use, with no per-app config or extra workflow step to maintain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nstrumentation

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>
…n 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>
Co-authored-by: isaacs <i@izs.me>
…t-utils

Move the duplicated per-app assert.mjs into a single, module-parameterized
`assertBundlerInstrumentation('graphql')` helper in @sentry-internal/test-utils,
collapsing each app's assert to one line. Along the way:

- Fix the Vite app to actually inline vs. externalize graphql via ssr.noExternal
  / ssr.external (rollupOptions.external is inert for Vite SSR builds), so the
  "inlined" variants exercise the build-time path they claim to.
- Assert bundle shape (inlined vs external), scanning every emitted chunk since
  bundlers split the entry's dynamic import (webpack) into sibling files.
- Hand the run result back through a file (SENTRY_E2E_RESULT_FILE) instead of
  stdout, so a piped, buffered write can't be truncated by the child's exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mydea
mydea force-pushed the fn/bundler-graphql-runtime branch from f49ca59 to 8b67446 Compare August 28, 2026 09:02
@mydea

mydea commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

@isaacs I refactored this a bit according to your feedback. I moved this as a shared utility into test-utils so we do not need to repeat this all the time. Also added a check to verify that it actually properly inlines/externalizes the library etc.

@mydea
mydea merged commit 617f6e3 into develop Aug 28, 2026
549 of 551 checks passed
@mydea
mydea deleted the fn/bundler-graphql-runtime branch August 28, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants