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', () => {