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
6 changes: 6 additions & 0 deletions dev-packages/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ is the reference setup.
because workerd does not allow it. `nextjs-16` has no Vite build: its Cloudflare variant builds with
`opennextjs-cloudflare build --config wrangler.opennext.jsonc` and wraps the generated Worker in `worker.ts` with
`withSentry` from `@sentry/nextjs/cloudflare`.
- **Cloudflare in `vite dev`**: to run the Worker in `vite dev` (for example the dev tests of the `hono-4` Cloudflare
variant), start it with `CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev`. The runner gives `E2E_TEST_DSN` to the app
only as a process env var. `wrangler dev` adds it to the Worker `env` with `--var "E2E_TEST_DSN:$E2E_TEST_DSN"`, but
`@cloudflare/vite-plugin` has no such option. It fills the Worker `env` from the `vars` of the wrangler config and
from `.dev.vars` or `.env`, and adds process env vars only when `CLOUDFLARE_INCLUDE_PROCESS_ENV` is `true`. Without
it, `env.E2E_TEST_DSN` is `undefined`, the SDK sends no events, and the tests fail.
- **Build flavors**: a variant can also build the same app with another tool, for example `nextjs-16 (vinext)` builds
`nextjs-16` with vinext. A build flavor is not a runtime: its assert script sets its own env var (`VINEXT=1`, read by
`tests/isVinext.ts`). It sets `runtime` only to run on another runtime, for example `nextjs-16 (vinext, bun)`.
Expand Down
4 changes: 3 additions & 1 deletion dev-packages/e2e-tests/test-applications/hono-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
"dev:bun": "bun run dist/entry.bun.js",
"dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts",
"dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"dev:cloudflare:vite": "CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev",
"test:build": "pnpm install && pnpm typecheck",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test",
"test:assert": "pnpm test:assert:node",
"test:assert:node": "RUNTIME=node pnpm test",
"test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test",
"test:assert:deno": "RUNTIME=deno pnpm test",
"test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test"
"test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test && RUNTIME=cloudflare pnpm test:dev"
},
"dependencies": {
"@sentry/bun": "latest || *",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ if (!testEnv) {
const APP_PORT = 8787;

const startCommands: Record<Runtime, string> = {
cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`,
// `vite dev` pre-bundles `hono` with its Rolldown dependency optimizer, so this also tests the transform there.
cloudflare:
testEnv === 'development'
? `pnpm dev:cloudflare:vite --port ${APP_PORT} --strictPort`
: `pnpm dev:cloudflare --port ${APP_PORT}`,
node: `pnpm dev:node`,
bun: `pnpm dev:bun`,
deno: `pnpm dev:deno`,
Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp
// Route the injected `tracingChannel` imports through the workerd-safe façade so a
// dependency wrapped at module scope (`const app = new Hono()`) doesn't throw on workerd.
dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel',
transformOptimizedDeps: true,
}),
sentryMastraObservabilityProviderPlugin(),
sentryFlueRuntimeProviderPlugin(),
Expand Down
7 changes: 7 additions & 0 deletions packages/server-utils/src/orchestrion/bundler/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export type PluginOptions = {
* routing every channel through it is harmless.
*/
dcModule?: string;
/**
* When `true`, the Vite plugin also runs the transform in the dependency optimizer of server environments in
* `vite dev`. Needed for `@cloudflare/vite-plugin`, which pre-bundles the dependencies of a Worker there.
*
* @default false
*/
transformOptimizedDeps?: boolean;
};

/**
Expand Down
166 changes: 128 additions & 38 deletions packages/server-utils/src/orchestrion/bundler/vite.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import esbuildCodeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild';
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite';
import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
import type { Plugin as EsbuildPlugin } from 'esbuild';
import type { ConfigEnv, ConfigPluginContext, EnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite';

export type { Plugin as VitePlugin } from 'vite';
import { instrumentedModuleNames } from '../config';
import type { PluginOptions } from './options';
import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options';
import { createExternalRequireResolver } from './externalRequire';
import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve';
import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER, SNIPPET_IMPORT_SPECIFIER_FILTER } from './resolve';

type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown;
type ResolveIdHandler = Extract<NonNullable<Plugin['resolveId']>, (...args: never[]) => unknown>;

// Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()`
// call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved`
// can find the first instance in a build also through a wrapper.
const INSTANCE_KEY = '__sentryOrchestrionInstance';

type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object };
type OrchestrionInstance = { transformOptimizedDeps: boolean };

// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of
// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without
// this gate the transform would also run in the CLIENT build — where modules
// like `@remix-run/server-runtime` sit in the client graph, and the injected
// snippet's import of the subscriber factories (which import
// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate
// on the `ssr` flag, which Vite passes on both major versions.
function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] {
type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: OrchestrionInstance };

function gateTransform(
transform: Plugin['transform'],
skip: (opts?: { ssr?: boolean }) => boolean,
): Plugin['transform'] {
const gate = (handler: TransformHandler): TransformHandler =>
function (code, id, opts) {
if (!opts?.ssr || isDuplicate()) {
if (skip(opts)) {
return null;
}
return handler.call(this, code, id, opts);
Expand All @@ -47,6 +48,74 @@ function toEsmEntry(id: string): string {
return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js');
}

// The module-injected snippet imports `@sentry/server-utils` from INSIDE
// transformed `node_modules` files. Under isolated installs (pnpm) that bare
// specifier doesn't resolve from an instrumented package's location, so when
// normal resolution fails, fall back to this package's own resolution so it
// gets bundled from its real on-disk path.
//
// Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier,
// which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external
// packages into default imports, and `@sentry/core` has no default export, so the server
// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`.
async function resolveSnippetImport(
this: ThisParameterType<ResolveIdHandler>,
...[source, importer, resolveOptions]: Parameters<ResolveIdHandler>
): Promise<Awaited<ReturnType<ResolveIdHandler>>> {
const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true });
if (resolved) {
return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) };
}
const fallback = resolveOrchestrionRuntimeRequest(source);
return fallback ? toEsmEntry(fallback) : null;
}

// Rolldown plugin for the dependency optimizer of Vite 8.
function depOptimizerRolldownPlugin(options: PluginOptions, isDuplicate: () => boolean): Plugin {
const upstream = codeTransformer(orchestrionTransformOptions(options));
return {
...upstream,
name: 'sentry-orchestrion-dep-optimizer',
transform: gateTransform(upstream.transform, isDuplicate),
resolveId(source, importer, resolveOptions) {
return source === SNIPPET_IMPORT_SPECIFIER
? resolveSnippetImport.call(this, source, importer, resolveOptions)
: null;
},
};
}

// esbuild plugin for the dependency optimizer of Vite 6 and 7.
function depOptimizerEsbuildPlugin(options: PluginOptions, isDuplicate: () => boolean): EsbuildPlugin {
const upstream = esbuildCodeTransformer(orchestrionTransformOptions(options));
return {
name: 'sentry-orchestrion-dep-optimizer',
setup(build) {
if (isDuplicate()) {
return;
}
Comment on lines +94 to +96

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 is function is gonna cause a crash if it's used in a node bundle with vite.

On Vite 6 and 7, the dependency optimizer uses platform: 'node' for any server environment that is not webworker (the default ssr environment of every Node SSR framework). For that platform, Vite adds this banner to every optimized file:

import { createRequire } from 'module';const require = createRequire(import.meta.url);

The new esbuild plugin makes the optimizer bundle @sentry/server-utils (and @sentry/core) into the pre-bundled dependency.

Two lines in depOptimizerEsbuildPlugin cause this. Line 112 (upstream.setup(build)) adds the transform, which puts an import of @sentry/server-utils into each instrumented module. Line 107 then resolves that import to a file path (toEsmEntry(resolved.path)), so esbuild bundles the file instead of leaving the import external. The @sentry/server-utils ESM build has its own top-level import { createRequire } from "module", and esbuild cannot rename around a text banner. The result is a file that doesn't parse:

vite 6.4.3 esm  load error: Identifier 'createRequire' has already been declared
vite 6.4.3 cjs  load error: Identifier 'createRequire' has already been declared
vite 7.3.6 esm  load error: Identifier 'createRequire' has already been declared
vite 7.3.6 cjs  load error: Identifier 'createRequire' has already been declared
vite 8.3.1 esm  runs: x channel fired: true
vite 8.3.1 cjs  runs: x channel fired: true

Cloudflare is not affected, because @cloudflare/vite-plugin sets platform: 'neutral', which has no banner. With platform: 'neutral' the Vite 6/7 ESM case loads and fires the channel.

Suggestion: scope the dev optimizer plugins to the case this PR targets. Two options:

  1. Minimal: don't register the resolver or the transform when the optimizer bundles for Node. Vite passes the platform it picked to esbuild as build.initialOptions.platform, so setup (line 91) can return before it reaches lines 96-112. The existing duplicate-instance check here already returns early in the same place, so the new check can go next to it:
Suggested change
if (isDuplicate()) {
return;
}
// Vite 6/7 add a `createRequire` banner for `platform: 'node'`, which
// collides with the `createRequire` import in the bundled SDK.
if (build.initialOptions.platform === 'node') {
return;
}
if (isDuplicate()) {
return;
}
  1. Cleaner: add an opt-in option to PluginOptions that sentryCloudflareVitePlugin sets, so the other SDKs keep today's serve behavior. The PR title says fix(cloudflare), and the comment at lines 205-207 names @cloudflare/vite-plugin as the reason. An opt-in would make the change match that scope. It would also avoid the side effects on Node SSR envs. This option touches three places: the new field in PluginOptions (packages/server-utils/src/orchestrion/bundler/options.ts), the serve branch at line 208 of vite.ts (return null when the option is off), and the sentryOrchestrionPlugin({ ... }) call at packages/cloudflare/src/vite/index.ts line 88.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm gonna check. I like the opt-in method.

// Same resolution as `resolveSnippetImport`. The `pluginData` marker stops the recursion back into this callback.
build.onResolve({ filter: SNIPPET_IMPORT_SPECIFIER_FILTER }, async args => {
if (args.pluginData === 'sentry-orchestrion-resolving') {
return null;
}
const resolved = await build.resolve(args.path, {
resolveDir: args.resolveDir,
importer: args.importer,
kind: args.kind,
pluginData: 'sentry-orchestrion-resolving',
});
if (resolved.errors.length === 0) {
return resolved.external ? resolved : { ...resolved, path: toEsmEntry(resolved.path) };
}
const fallback = resolveOrchestrionRuntimeRequest(args.path);
return fallback ? { path: toEsmEntry(fallback) } : null;
});
return upstream.setup(build);
},
};
}

/**
* Vite plugin that runs the orchestrion code transform on the bundled output.
*
Expand Down Expand Up @@ -76,38 +145,30 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
];
const resolveExternalRequire = createExternalRequireResolver(noExternalModules());

const instance = {};
const instance: OrchestrionInstance = { transformOptimizedDeps: options.transformOptimizedDeps === true };
// `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone.
let isDuplicate = false;
// `true` when an earlier instance with `transformOptimizedDeps` is in the same build, which then transforms the
// pre-bundled dependencies alone.
let isDuplicateOptimizer = false;

const plugin: OrchestrionVitePlugin = {
...upstream,
[INSTANCE_KEY]: instance,
transform: ssrOnlyTransform(upstream.transform, () => isDuplicate),
// The module-injected snippet imports `@sentry/server-utils` from INSIDE
// transformed `node_modules` files. Under isolated installs (pnpm) that bare
// specifier doesn't resolve from an instrumented package's location, so when
// normal resolution fails, fall back to this package's own resolution so it
// gets bundled from its real on-disk path. SSR-gated like the transform: the
// specifier only appears in SSR modules.
//
// Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier,
// which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external
// packages into default imports, and `@sentry/core` has no default export, so the server
// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`.
// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of
// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without
// this gate the transform would also run in the CLIENT build — where modules
// like `@remix-run/server-runtime` sit in the client graph, and the injected
// snippet's import of the subscriber factories (which import
// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate
// on the `ssr` flag, which Vite passes on both major versions.
transform: gateTransform(upstream.transform, opts => !opts?.ssr || isDuplicate),
// SSR-gated like the transform: the specifier only appears in SSR modules.
async resolveId(source, importer, resolveOptions) {
if (source !== SNIPPET_IMPORT_SPECIFIER) {
return resolveExternalRequire.call(this, source, importer, resolveOptions);
}
if (!resolveOptions?.ssr) {
return null;
}
const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true });
if (resolved) {
return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) };
}
const fallback = resolveOrchestrionRuntimeRequest(source);
return fallback ? toEsmEntry(fallback) : null;
return resolveOptions?.ssr ? resolveSnippetImport.call(this, source, importer, resolveOptions) : null;
},
applyToEnvironment(environment) {
// Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only
Expand Down Expand Up @@ -136,21 +197,50 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
},
},
configEnvironment(
// Vite 6 calls this hook without a context.
this: ConfigPluginContext | undefined,
name: string,
config: { consumer?: 'client' | 'server' },
env?: ConfigEnv,
): { resolve: { noExternal: string[] } } | null {
if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
): EnvironmentOptions | null {
if ((config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
return null;
}

// `@cloudflare/vite-plugin` pre-bundles the dependencies of a Worker in `serve`. The dependency
// optimizer runs no Vite plugins, so the transform has to run inside it. A Node server loads its
// dependencies with Node, where the runtime hook instruments them.
if (env?.command === 'serve') {
if (!options.transformOptimizedDeps) {
return null;
}
if (this?.meta?.rolldownVersion) {
return {
optimizeDeps: {
rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicateOptimizer)] },
},
};
}
return {
optimizeDeps: {
// Vite 6 and 7 have no `rolldownOptions`.
// oxlint-disable-next-line typescript/no-deprecated
esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicateOptimizer)] },
},
};
}

return { resolve: { noExternal: noExternalModules() } };
},
configResolved(config: ResolvedConfig): void {
// Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and
// `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build.
const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]);
isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance;
const instances = (config.plugins as readonly OrchestrionVitePlugin[]).flatMap(p => p[INSTANCE_KEY] ?? []);
isDuplicate = instances[0] !== undefined && instances[0] !== instance;
// The first instance can be one without `transformOptimizedDeps`, for example the one of `sentryReactRouter`
// before `sentryCloudflareVitePlugin`.
const firstOptimizer = instances.find(i => i.transformOptimizedDeps);
isDuplicateOptimizer = firstOptimizer !== undefined && firstOptimizer !== instance;

// Nothing is force-bundled in `serve`, so an externalized module is expected there.
if (isDuplicate || config.command === 'serve') {
Expand Down
Loading
Loading