Skip to content
Draft
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
Expand Up @@ -10,8 +10,7 @@ export default defineConfig(async config => ({
reactRouter(),
sentryCloudflareVitePlugin(),
...((await sentryReactRouter(
// Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build.
{ sourcemaps: { disable: true }, buildTimeInstrumentation: false },
{ sourcemaps: { disable: true } },
config,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any[]),
Expand Down
22 changes: 17 additions & 5 deletions packages/server-utils/src/orchestrion/bundler/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './re

type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown;

const PLUGIN_NAME = 'sentry-orchestrion';

// 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']): Plugin['transform'] {
function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] {
const gate = (handler: TransformHandler): TransformHandler =>
function (code, id, opts) {
if (!opts?.ssr) {
if (!opts?.ssr || isDuplicate()) {
return null;
}
return handler.call(this, code, id, opts);
Expand Down Expand Up @@ -62,9 +64,13 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
'@sentry/server-utils',
];

return {
// `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone.
let isDuplicate = false;

const plugin: Plugin = {
...upstream,
transform: ssrOnlyTransform(upstream.transform),
name: PLUGIN_NAME,
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
Expand Down Expand Up @@ -119,8 +125,12 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
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.
isDuplicate = config.plugins.find(p => p.name === PLUGIN_NAME) !== plugin;

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

Expand All @@ -140,4 +150,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
}
},
};

return plugin;
}
32 changes: 32 additions & 0 deletions packages/server-utils/test/orchestrion/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ describe('sentryOrchestrionPlugin (vite)', () => {
const plugin = vitePlugin();
(plugin.configResolved as (config: unknown) => void)({
command,
plugins: [plugin],
ssr: { external: ssrExternal },
logger: { warn },
} as unknown as ResolvedConfig);
Expand Down Expand Up @@ -324,6 +325,37 @@ describe('sentryOrchestrionPlugin (vite)', () => {
expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed');
});

it('transforms and warns only in the first instance when two Sentry plugins add it to one build', () => {
const first = vitePlugin();
const second = vitePlugin();
const warn = vi.fn();
const config = {
command: 'build',
plugins: [{ name: 'other' }, first, second],
ssr: { external: ['mysql'] },
logger: { warn },
} as unknown as ResolvedConfig;

(first.configResolved as (config: unknown) => void)(config);
(second.configResolved as (config: unknown) => void)(config);

const firstTransform = first.transform as (
this: unknown,
code: string,
id: string,
opts?: { ssr?: boolean },
) => unknown;
const secondTransform = second.transform as (
this: unknown,
code: string,
id: string,
opts?: { ssr?: boolean },
) => unknown;
expect(firstTransform.call({}, 'code', 'id', { ssr: true })).toBe('transformed');
expect(secondTransform.call({}, 'code', 'id', { ssr: true })).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
});

it('gates resolveId on the ssr flag and falls back to self-resolution', async () => {
const plugin = vitePlugin();
const resolveId = plugin.resolveId as (
Expand Down
Loading