diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.html b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.html
new file mode 100644
index 000000000000..56d4966205e9
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.html
@@ -0,0 +1,7 @@
+
+
+
+ orchestrion build all environments
+
+
+
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.ts
new file mode 100644
index 000000000000..85597d292dbc
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/index.ts
@@ -0,0 +1,10 @@
+import { streamText } from 'ai';
+
+export default {
+ async fetch(request: Request): Promise {
+ if (new URL(request.url).pathname === '/worker') {
+ return new Response(`streamText: ${typeof streamText}`);
+ }
+ return new Response('not found', { status: 404 });
+ },
+};
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/instrument.server.ts
new file mode 100644
index 000000000000..e577550ae0b8
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/instrument.server.ts
@@ -0,0 +1,6 @@
+import { defineCloudflareOptions } from '@sentry/cloudflare';
+
+export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
+ dsn: env.SENTRY_DSN,
+ tracesSampleRate: 1.0,
+}));
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/test.ts
new file mode 100644
index 000000000000..aec0b0e717fc
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/test.ts
@@ -0,0 +1,37 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
+import { SENTRY_OP, SENTRY_ORIGIN, URL_PATH } from '@sentry/conventions/attributes';
+import { readdirSync, readFileSync } from 'fs';
+import { join } from 'path';
+import { expect, it } from 'vitest';
+import { createRunner } from '../../../../runner';
+
+// Regression test: a top-level `ssr` option from the orchestrion plugin made Vite create an extra
+// `ssr` environment without an entry. A `buildApp` that builds every environment then failed with
+// "input should not be an html file when building for SSR".
+it('builds when buildApp builds every Vite environment', async ({ signal }) => {
+ const runner = createRunner(__dirname)
+ .unordered()
+ .expect(envelope => {
+ const spanItem = envelope[1].find(item => item[0].type === 'span');
+ const container = spanItem?.[1] as SerializedStreamedSpanContainer;
+ const serverSpan = container.items.find(item => item.is_segment);
+
+ expect(serverSpan?.attributes[SENTRY_OP]?.value).toBe('http.server');
+ expect(serverSpan?.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare');
+ expect(serverSpan?.attributes[URL_PATH]?.value).toBe('/worker');
+ })
+ .start(signal);
+
+ const response = await runner.makeRequest('get', '/worker');
+ expect(response).toBe('streamText: function');
+ await runner.completed();
+
+ expect(readdirSync(join(__dirname, 'dist')).sort()).toEqual(['client', 'cloudflare_vite_dc_build_all_environments']);
+
+ const workerDir = join(__dirname, 'dist', 'cloudflare_vite_dc_build_all_environments');
+ const workerBundle = readdirSync(workerDir)
+ .filter(name => /\.m?js$/.test(name))
+ .map(name => readFileSync(join(workerDir, name), 'utf8'))
+ .join('\n');
+ expect(workerBundle).toContain('orchestrion:ai:streamText');
+});
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/vite.config.mts
new file mode 100644
index 000000000000..4ff6b45f51a5
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/vite.config.mts
@@ -0,0 +1,15 @@
+import { cloudflare } from '@cloudflare/vite-plugin';
+import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [cloudflare(), sentryCloudflareVitePlugin()],
+ // Builds every Vite environment, as `vite build --app` does.
+ builder: {
+ async buildApp(builder) {
+ for (const environment of Object.values(builder.environments)) {
+ await builder.build(environment);
+ }
+ },
+ },
+});
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/wrangler.jsonc
new file mode 100644
index 000000000000..b958c3054b2f
--- /dev/null
+++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/build-all-environments/wrangler.jsonc
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../../node_modules/wrangler/config-schema.json",
+ "name": "cloudflare-vite-dc-build-all-environments",
+ "main": "index.ts",
+ "compatibility_date": "2026-04-26",
+ "compatibility_flags": ["nodejs_compat"],
+ "assets": {
+ "directory": "./dist/client",
+ },
+}
diff --git a/packages/remix/src/vite/orchestrionPlugin.ts b/packages/remix/src/vite/orchestrionPlugin.ts
index 26df048242d9..1bd4c91e7051 100644
--- a/packages/remix/src/vite/orchestrionPlugin.ts
+++ b/packages/remix/src/vite/orchestrionPlugin.ts
@@ -57,6 +57,7 @@ export function makeOrchestrionPlugin(options: Pick | undefined);
const configResolved = hookHandler(orchestrion.configResolved);
+ let isWorkerConfig = false;
let isWorkerBuild = false;
return {
@@ -66,9 +67,13 @@ export function makeOrchestrionPlugin(options: Pick | undefined, () => isWorkerConfig),
// The authoritative check: the resolved config reflects every plugin regardless of ordering,
// and this always runs before the first `transform`.
configResolved(resolvedConfig: ResolvedConfig) {
diff --git a/packages/remix/test/vite/index.test.ts b/packages/remix/test/vite/index.test.ts
index efaff6ddda83..ad4d212fdd64 100644
--- a/packages/remix/test/vite/index.test.ts
+++ b/packages/remix/test/vite/index.test.ts
@@ -7,6 +7,7 @@ import { sentryRemixVitePlugin } from '../../src/vite';
const orchestrionConfig = vi.fn((_config: UserConfig, env: ConfigEnv) =>
env.command === 'serve' ? null : { ssr: { noExternal: ['mysql'] } },
);
+const orchestrionConfigEnvironment = vi.fn(() => ({ resolve: { noExternal: ['mysql'] } }));
const orchestrionConfigResolved = vi.fn();
const orchestrionTransform = vi.fn(() => ({ code: 'transformed' }));
@@ -17,6 +18,7 @@ const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean })
name: 'code-transformer',
enforce: 'pre',
config: orchestrionConfig,
+ configEnvironment: orchestrionConfigEnvironment,
configResolved: orchestrionConfigResolved,
transform: orchestrionTransform,
},
@@ -86,6 +88,9 @@ describe('sentryRemixVitePlugin', () => {
const orchestrion = sentryRemixVitePlugin()[1]!;
expect(callHook(orchestrion.config, NODE_CONFIG, BUILD_ENV)).toEqual({ ssr: { noExternal: ['mysql'] } });
+ expect(callHook(orchestrion.configEnvironment, 'ssr', {}, BUILD_ENV)).toEqual({
+ resolve: { noExternal: ['mysql'] },
+ });
callHook(orchestrion.configResolved, NODE_CONFIG);
expect(orchestrionConfigResolved).toHaveBeenCalledTimes(1);
@@ -99,6 +104,9 @@ describe('sentryRemixVitePlugin', () => {
expect(callHook(orchestrion.config, config, BUILD_ENV)).toBeNull();
expect(orchestrionConfig).not.toHaveBeenCalled();
+ expect(callHook(orchestrion.configEnvironment, 'ssr', {}, BUILD_ENV)).toBeNull();
+ expect(orchestrionConfigEnvironment).not.toHaveBeenCalled();
+
callHook(orchestrion.configResolved, config);
expect(orchestrionConfigResolved).not.toHaveBeenCalled();
diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts
index ebed4b2f2ba4..0b8f66d961f4 100644
--- a/packages/server-utils/src/orchestrion/bundler/vite.ts
+++ b/packages/server-utils/src/orchestrion/bundler/vite.ts
@@ -1,5 +1,5 @@
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite';
-import type { ConfigEnv, Plugin, ResolvedConfig } from 'vite';
+import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
export type { Plugin as VitePlugin } from 'vite';
import { instrumentedModuleNames } from '../config';
@@ -57,6 +57,10 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
}
const upstream = codeTransformer(orchestrionTransformOptions(options));
+ const noExternalModules = (): string[] => [
+ ...instrumentedModuleNames(options.instrumentations),
+ '@sentry/server-utils',
+ ];
return {
...upstream,
@@ -83,32 +87,36 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
// calls never land in a browser (`client`) bundle (where they'd throw `X is not a function`).
return environment.config.consumer === 'server';
},
- config(_config: unknown, env?: ConfigEnv): { ssr: { noExternal: string[] } } | null {
- // Vite's dev SSR runner has no CommonJS interop, so an inlined `ioredis`/`mysql` throws
- // `exports is not defined` on first import. Left external they stay on Node's loader, where
- // the runtime hook `Sentry.init()` registers injects the same publishers.
- if (env?.command === 'serve') {
+ // Vite externalizes dependencies in SSR builds, so the transform only sees an instrumented
+ // package when it is bundled. `@sentry/server-utils` is bundled too, because the injected
+ // snippet `require()`s it, and Vite 5's CJS interop turns that into a default import of our
+ // ESM entry, which crashes at startup.
+ // Not in `serve`: Vite's dev SSR runner has no CJS interop, so inlined `mysql`/`ioredis` throw
+ // `exports is not defined`, and the runtime hook injects the same publishers instead.
+ config: {
+ // Runs after the framework plugins, so `build.ssr` set by their `config` hooks is visible.
+ order: 'post',
+ handler(config: UserConfig, env?: ConfigEnv): { ssr: { noExternal: string[] } } | null {
+ // A top-level `ssr` key makes Vite 6+ add an `ssr` environment with no entry, which
+ // `vite build --app` cannot build. Vite 5 has no `configEnvironment` and needs the key, and
+ // its SSR builds always set `build.ssr`.
+ if (env?.command === 'serve' || !(config.ssr || config.build?.ssr)) {
+ return null;
+ }
+
+ return { ssr: { noExternal: noExternalModules() } };
+ },
+ },
+ configEnvironment(
+ name: string,
+ config: { consumer?: 'client' | 'server' },
+ env?: ConfigEnv,
+ ): { resolve: { noExternal: string[] } } | null {
+ if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
return null;
}
- // Force-bundle every instrumented package so the code transform actually
- // sees its source. Vite externalizes dependencies in SSR builds by
- // default, leaving them as bare `require()`/`import` calls resolved from
- // `node_modules` at runtime — those copies are untouched and the
- // diagnostics_channel calls never get injected. Vite merges array
- // `noExternal` entries with the user's config, so we don't overwrite
- // their additions.
- //
- // `@sentry/server-utils` must be bundled too: the module-injected snippet
- // `require()`s it from inside transformed CJS deps, and when the package
- // stays external, Vite 5's CommonJS interop (`esmExternals: false`)
- // rewrites that require into a DEFAULT import of our named-exports-only
- // ESM entry — a link-time crash at server startup. Bundling sidesteps
- // external ESM/CJS interop on both Vite majors, and the ESM barrel
- // tree-shakes to just the helper and the factories actually referenced.
- return {
- ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] },
- };
+ return { resolve: { noExternal: noExternalModules() } };
},
configResolved(config: ResolvedConfig): void {
// Nothing is force-bundled in `serve`, so an externalized module is expected there.
diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts
index 93836ef96ebf..b04fa7d1f160 100644
--- a/packages/server-utils/test/orchestrion/bundler.test.ts
+++ b/packages/server-utils/test/orchestrion/bundler.test.ts
@@ -233,10 +233,29 @@ describe('sentryOrchestrionPlugin (vite)', () => {
return warn;
}
- function runConfig(command: 'build' | 'serve'): { ssr: { noExternal: string[] } } | null {
+ function runConfig(
+ command: 'build' | 'serve',
+ userConfig: Record = { build: { ssr: true } },
+ ): { ssr: { noExternal: string[] } } | null {
const plugin = vitePlugin();
- const config = plugin.config as (config: unknown, env: unknown) => { ssr: { noExternal: string[] } } | null;
- return config.call(plugin, {}, { command, mode: 'production' });
+ const config = plugin.config as {
+ handler: (config: unknown, env: unknown) => { ssr: { noExternal: string[] } } | null;
+ };
+ return config.handler.call(plugin, userConfig, { command, mode: 'production' });
+ }
+
+ function runConfigEnvironment(
+ name: string,
+ environmentConfig: Record,
+ command: 'build' | 'serve' = 'build',
+ ): { resolve: { noExternal: string[] } } | null {
+ const plugin = vitePlugin();
+ const configEnvironment = plugin.configEnvironment as (
+ name: string,
+ config: unknown,
+ env: unknown,
+ ) => { resolve: { noExternal: string[] } } | null;
+ return configEnvironment.call(plugin, name, environmentConfig, { command, mode: 'production' });
}
it('warns when instrumented modules are listed in ssr.external', () => {
@@ -266,6 +285,22 @@ describe('sentryOrchestrionPlugin (vite)', () => {
it('does not force-bundle instrumented modules on the dev server', () => {
// Inlined in dev, the CommonJS drivers throw `exports is not defined` on import.
expect(runConfig('serve')).toBeNull();
+ expect(runConfigEnvironment('ssr', {}, 'serve')).toBeNull();
+ });
+
+ it('adds the top-level ssr option only when the config already has ssr or build.ssr', () => {
+ expect(runConfig('build', {})).toBeNull();
+ expect(runConfig('build', { ssr: { target: 'node' } })?.ssr.noExternal).toContain('mysql');
+ expect(runConfig('build', { build: { ssr: 'src/server.ts' } })?.ssr.noExternal).toContain('mysql');
+ });
+
+ it('force-bundles instrumented modules in server environments', () => {
+ expect(runConfigEnvironment('ssr', {})?.resolve.noExternal).toEqual(
+ expect.arrayContaining(['mysql', '@sentry/server-utils']),
+ );
+ expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve.noExternal).toContain('mysql');
+ expect(runConfigEnvironment('client', {})).toBeNull();
+ expect(runConfigEnvironment('browser', { consumer: 'client' })).toBeNull();
});
it('does not warn about externalized instrumented modules on the dev server', () => {