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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head>
<title>orchestrion build all environments</title>
</head>
<body></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { streamText } from 'ai';

export default {
async fetch(request: Request): Promise<Response> {
if (new URL(request.url).pathname === '/worker') {
return new Response(`streamText: ${typeof streamText}`);
}
return new Response('not found', { status: 404 });
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -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<string>('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');
});
Original file line number Diff line number Diff line change
@@ -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);
}
},
},
});
Original file line number Diff line number Diff line change
@@ -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",
},
}
7 changes: 6 additions & 1 deletion packages/remix/src/vite/orchestrionPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function makeOrchestrionPlugin(options: Pick<SentryRemixVitePluginOptions
const config = hookHandler(orchestrion.config as ObjectHook<ConfigHook> | undefined);
const configResolved = hookHandler(orchestrion.configResolved);

let isWorkerConfig = false;
let isWorkerBuild = false;

return {
Expand All @@ -66,9 +67,13 @@ export function makeOrchestrionPlugin(options: Pick<SentryRemixVitePluginOptions
config: {
order: 'post',
handler(userConfig: UserConfig, env: ConfigEnv) {
return isWorkerTarget(userConfig) ? null : (config?.(userConfig, env) ?? null);
isWorkerConfig = isWorkerTarget(userConfig);
return isWorkerConfig ? null : (config?.(userConfig, env) ?? null);
},
},
// Gated on the worker check from the `config` hook, because Vite calls `configEnvironment`
// before `configResolved`.
configEnvironment: gateHook(orchestrion.configEnvironment as ObjectHook<AnyHook> | 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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/remix/test/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));

Expand All @@ -17,6 +18,7 @@ const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean })
name: 'code-transformer',
enforce: 'pre',
config: orchestrionConfig,
configEnvironment: orchestrionConfigEnvironment,
configResolved: orchestrionConfigResolved,
transform: orchestrionTransform,
},
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand Down
56 changes: 32 additions & 24 deletions packages/server-utils/src/orchestrion/bundler/vite.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
41 changes: 38 additions & 3 deletions packages/server-utils/test/orchestrion/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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<string, unknown>,
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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading