Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 59 additions & 34 deletions dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs
Original file line number Diff line number Diff line change
@@ -1,57 +1,82 @@
/**
* 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 the built bundles across the build-time and runtime instrumentation paths and asserts that
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
*
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
* an external module, so the runtime hook
* is the sole injector and there is no
* double instrumentation.
*
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
* line, which fails the assert rather than being silently swallowed.
*
* @module
*/
import { 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;
}

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

const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;

const scenarios = {
plain: run('plain'),
plugin: run('plugin'),
plainExternalImport: run('plain-external', { withImport: true }),
pluginExternalImport: run('plugin-external', { withImport: true }),
};

// One set of graphql spans, established by the build-time run.
const oneSet = graphqlSpanCount(scenarios.plugin);

let failed = false;
function check(condition, message) {
// eslint-disable-next-line no-console
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
if (!condition) failed = true;
}

const plain = bundleText('plain');
const plugin = bundleText('plugin');
for (const [label, result] of Object.entries(scenarios)) {
check(result.data?.hello === 'world', `${label}: graphql query works`);
}

check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
check(
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
);
check(
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
'sentryEsbuildPlugin runs build-time instrumentation (injects the orchestrion banner)',
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
);

if (failed) {
Expand Down
53 changes: 31 additions & 22 deletions dev-packages/e2e-tests/test-applications/node-esbuild/build.mjs
Original file line number Diff line number Diff line change
@@ -1,41 +1,50 @@
// 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 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';
import { build } from 'esbuild';
import { sentryEsbuildPlugin } from '@sentry/node/esbuild';

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

function run(name, plugins) {
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });

// 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')],
outdir: join(__dirname, 'dist', name),
outfile: join(__dirname, 'dist', name, 'main.cjs'),
bundle: true,
platform: 'node',
format: 'esm',
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');
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -15,6 +15,7 @@
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
},
"devDependencies": {
"graphql": "16.9.0",
"esbuild": "0.28.2"
},
"volta": {
Expand Down
12 changes: 10 additions & 2 deletions dev-packages/e2e-tests/test-applications/node-esbuild/src/app.mjs
Original file line number Diff line number Diff line change
@@ -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' } });
}
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
cursor[bot] marked this conversation as resolved.
}

void main();
Comment thread
cursor[bot] marked this conversation as resolved.
93 changes: 59 additions & 34 deletions dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs
Original file line number Diff line number Diff line change
@@ -1,57 +1,82 @@
/**
* 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 the built bundles across the build-time and runtime instrumentation paths and asserts that
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
*
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
* an external module, so the runtime hook
* is the sole injector and there is no
* double instrumentation.
*
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
* line, which fails the assert rather than being silently swallowed.
*
* @module
*/
import { 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;
}

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

const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;

const scenarios = {
plain: run('plain'),
plugin: run('plugin'),
plainExternalImport: run('plain-external', { withImport: true }),
pluginExternalImport: run('plugin-external', { withImport: true }),
};

// One set of graphql spans, established by the build-time run.
const oneSet = graphqlSpanCount(scenarios.plugin);

let failed = false;
function check(condition, message) {
// eslint-disable-next-line no-console
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
if (!condition) failed = true;
}

const plain = bundleText('plain');
const plugin = bundleText('plugin');
for (const [label, result] of Object.entries(scenarios)) {
check(result.data?.hello === 'world', `${label}: graphql query works`);
}

check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
check(
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
);
check(
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)',
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
);

if (failed) {
Expand Down
Loading
Loading