Skip to content
Merged
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
16 changes: 12 additions & 4 deletions .github/actions/install-playwright/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ inputs:
runs:
using: 'composite'
steps:
# Resolve the app's @playwright/test version. Some apps (e.g. the node bundler apps) assert via a
# plain node script and don't depend on Playwright — emit an empty version instead of failing, so
# the steps below skip the (wasted) browser install rather than erroring on the missing package.
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
run: |
version=$(node -e "let v = ''; try { v = require('@playwright/test/package.json').version; } catch {} process.stdout.write(String(v));")
echo "version=$version" >> "$GITHUB_OUTPUT"
shell: bash
working-directory: ${{ inputs.cwd }}

- name: Restore cached playwright binaries
uses: actions/cache/restore@v6.1.0
id: playwright-cache
if: steps.playwright-version.outputs.version != ''
with:
path: |
~/.cache/ms-playwright
Expand All @@ -29,22 +35,24 @@ runs:
# We always install all browsers, if uncached
- name: Install Playwright dependencies (uncached)
run: npx playwright install chromium webkit firefox --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
working-directory: ${{ inputs.cwd }}

- name: Install Playwright system dependencies only (cached)
env:
PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }}
run: npx playwright install-deps "$PLAYWRIGHT_BROWSERS"
if: steps.playwright-cache.outputs.cache-hit == 'true'
if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
working-directory: ${{ inputs.cwd }}

# Only store cache on develop branch
- name: Store cached playwright binaries
uses: actions/cache/save@v6.1.0
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
if:
steps.playwright-version.outputs.version != '' && github.event_name == 'push' && github.ref ==
'refs/heads/develop'
with:
path: |
~/.cache/ms-playwright
Expand Down
66 changes: 6 additions & 60 deletions dev-packages/e2e-tests/test-applications/node-esbuild/assert.mjs
Original file line number Diff line number Diff line change
@@ -1,61 +1,7 @@
/**
* 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.
*
* @module
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertBundlerInstrumentation } from '@sentry-internal/test-utils';

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, '');
}

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');

check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
check(
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
'sentryEsbuildPlugin runs build-time instrumentation (injects the orchestrion banner)',
);

if (failed) {
process.exit(1);
}
// eslint-disable-next-line no-console
console.log('All bundle assertions passed.');
// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the
// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each
// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation`
// in `@sentry-internal/test-utils` for the full matrix.
assertBundlerInstrumentation('graphql');
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,8 @@
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
},
"devDependencies": {
"@sentry-internal/test-utils": "link:../../../test-utils",
"graphql": "16.9.0",
"esbuild": "0.28.2"
},
"volta": {
Expand Down
14 changes: 12 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,12 @@
// eslint-disable-next-line no-console
console.log('this is the application');
// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the
// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion
// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range
// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay
// library-agnostic.
import { buildSchema, graphql } from 'graphql';

const schema = buildSchema('type Query { hello: String }');

export async function runWorkload() {
return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } });
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,48 @@
// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs
// first so the instrumentation's channel subscriber is ready, then the workload is imported and run.
// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written
// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return
// value rides along as `result` so the assertion can check it without knowing what the workload does.
//
// 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 { writeFileSync } from 'node:fs';
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 { runWorkload } = await import('./app.mjs');

let result;
await Sentry.startSpan({ name: 'workload' }, async () => {
result = await runWorkload();
});

await Sentry.flush(2000);

const resultFile = process.env.SENTRY_E2E_RESULT_FILE;
if (!resultFile) {
throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).');
}
// Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit
// can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains).
writeFileSync(resultFile, JSON.stringify({ result, spans }));
process.exit(0);
Comment thread
cursor[bot] marked this conversation as resolved.
}

void main();
Comment thread
cursor[bot] marked this conversation as resolved.
66 changes: 6 additions & 60 deletions dev-packages/e2e-tests/test-applications/node-rolldown/assert.mjs
Original file line number Diff line number Diff line change
@@ -1,61 +1,7 @@
/**
* 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.
*
* @module
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertBundlerInstrumentation } from '@sentry-internal/test-utils';

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, '');

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

}

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');

check(!plain.includes(BUILD_TIME_TRANSFORM_MARKER), 'plain build (no plugin) does not run build-time instrumentation');
check(
plugin.includes(BUILD_TIME_TRANSFORM_MARKER),
'sentryRollupPlugin runs build-time instrumentation (injects the orchestrion banner)',
);

if (failed) {
process.exit(1);
}
// eslint-disable-next-line no-console
console.log('All bundle assertions passed.');
// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the
// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each
// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation`
// in `@sentry-internal/test-utils` for the full matrix.
assertBundlerInstrumentation('graphql');
Loading
Loading