From e070d45e7b75af613ec18d31bc04ed4a83cc6e63 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 3 Sep 2026 11:21:51 +0200 Subject: [PATCH 1/2] feat(remix): Auto-wire orchestrion build-time instrumentation `sentryRemixVitePlugin()` now adds the orchestrion bundler plugin automatically, so bundled SSR builds get `diagnostics_channel` publishers injected with no manual plugin setup. Remix was the last metaframework SDK still requiring users to wire `sentryOrchestrionPlugin()` themselves. Builds targeting workerd (Cloudflare Pages, Hydrogen/Oxygen) are skipped: they instrument through `instrumentBuild()` from `@sentry/remix/cloudflare` and never subscribe to the injected channels. Fixes #23986 Refs #22632 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0189LWkpCsFqEwM5EFoT11TJ --- MIGRATION.md | 2 + .../create-remix-app-v2/package.json | 1 - .../create-remix-app-v2/vite.config.ts | 5 - packages/remix/src/vite/index.ts | 8 +- packages/remix/src/vite/orchestrionPlugin.ts | 68 +++++++++++ packages/remix/src/vite/types.ts | 11 ++ packages/remix/test/vite/index.test.ts | 113 ++++++++++++++++++ 7 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 packages/remix/src/vite/orchestrionPlugin.ts create mode 100644 packages/remix/test/vite/index.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index fa62f96a2033..4f7b8e4c368a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1272,6 +1272,8 @@ Affected SDKs: `@sentry/remix`. + import { sentryRemixVitePlugin } from '@sentry/remix/vite'; ``` +The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. + ## 3. Removed APIs ### `@sentry/core` / All SDKs diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json index 269fe3571de7..ecdfe08fa487 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "@remix-run/css-bundle": "2.17.4", "@remix-run/node": "2.17.4", "@remix-run/react": "2.17.4", diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts index 381cc9d13ecc..690acdb91168 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts @@ -1,6 +1,5 @@ import { vitePlugin as remix } from '@remix-run/dev'; import { sentryRemixVitePlugin } from '@sentry/remix/vite'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; @@ -10,10 +9,6 @@ export default defineConfig({ ignoredRouteFiles: ['**/.*'], }), sentryRemixVitePlugin(), - // Run the orchestrion code transform over the SSR server bundle and force-bundle the - // instrumented deps (mysql, ioredis, @remix-run/server-runtime, …) so their - // diagnostics-channel calls are injected at build time. - sentryOrchestrionPlugin(), tsconfigPaths(), ], }); diff --git a/packages/remix/src/vite/index.ts b/packages/remix/src/vite/index.ts index 001165f380ab..3689266f2f44 100644 --- a/packages/remix/src/vite/index.ts +++ b/packages/remix/src/vite/index.ts @@ -1,4 +1,5 @@ import type { Plugin } from 'vite'; +import { makeOrchestrionPlugin } from './orchestrionPlugin'; import { makeRouteManifestPlugin } from './routeManifestPlugin'; import type { SentryRemixVitePluginOptions } from './types'; @@ -7,8 +8,9 @@ export type { SentryRemixVitePluginOptions }; /** * Sentry Vite plugins for Remix. * - * Add these to your Vite configuration to inject the Remix route manifest, so client-side - * transactions are parameterized. + * Add these to your Vite configuration to + * - inject the Remix route manifest, so client-side transactions are parameterized, and + * - build-time instrument supported server-side dependencies (such as database clients). * * @example * ```typescript @@ -28,5 +30,5 @@ export type { SentryRemixVitePluginOptions }; * ``` */ export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin[] { - return [makeRouteManifestPlugin(options)]; + return [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; } diff --git a/packages/remix/src/vite/orchestrionPlugin.ts b/packages/remix/src/vite/orchestrionPlugin.ts new file mode 100644 index 000000000000..5c330166c12f --- /dev/null +++ b/packages/remix/src/vite/orchestrionPlugin.ts @@ -0,0 +1,68 @@ +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryRemixVitePluginOptions } from './types'; + +type AnyHook = (this: unknown, ...args: never[]) => unknown; +type ObjectHook = T | { order?: 'pre' | 'post' | null; handler: T }; +type ConfigHook = (this: unknown, config: UserConfig, env: ConfigEnv) => unknown; + +/** + * Cloudflare Pages and Hydrogen/Oxygen builds instrument through `instrumentBuild()` from + * `@sentry/remix/cloudflare`, which wraps the build instead of subscribing to diagnostics + * channels. Transforming there would add a `node:diagnostics_channel` import and subscriber code + * that nothing reads. + */ +function isWorkerTarget(config: UserConfig | ResolvedConfig | undefined): boolean { + return config?.ssr?.target === 'webworker'; +} + +function hookHandler(hook: ObjectHook | undefined): T | undefined { + return typeof hook === 'function' ? hook : hook?.handler; +} + +/** No-ops a hook while `isDisabled()` holds, keeping its declared hook shape. */ +function gateHook( + hook: ObjectHook | undefined, + isDisabled: () => boolean, +): ObjectHook | undefined { + if (!hook) { + return hook; + } + + const handler = hookHandler(hook) as AnyHook; + const gated = function (this: unknown, ...args: never[]): unknown { + return isDisabled() ? null : handler.apply(this, args); + } as T; + + return typeof hook === 'function' ? gated : { ...hook, handler: gated }; +} + +/** The orchestrion bundler plugin, wired to stay out of workerd builds. */ +export function makeOrchestrionPlugin(options: Pick): Plugin { + const orchestrion = sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation }); + const { renderChunk } = orchestrion as Plugin & { renderChunk?: ObjectHook }; + const config = hookHandler(orchestrion.config as ObjectHook | undefined); + const configResolved = hookHandler(orchestrion.configResolved); + + let isWorkerBuild = false; + + return { + ...orchestrion, + // Upstream ships the plugin with `enforce: 'pre'`, which would run this hook before the + // framework plugins that set `ssr.target`. `order: 'post'` moves it after them. + config: { + order: 'post', + handler(userConfig: UserConfig, env: ConfigEnv) { + return isWorkerTarget(userConfig) ? null : (config?.(userConfig, env) ?? null); + }, + }, + // The authoritative check: the resolved config reflects every plugin regardless of ordering, + // and this always runs before the first `transform`. + configResolved(resolvedConfig: ResolvedConfig) { + isWorkerBuild = isWorkerTarget(resolvedConfig); + return isWorkerBuild ? undefined : configResolved?.(resolvedConfig); + }, + transform: gateHook(orchestrion.transform as ObjectHook | undefined, () => isWorkerBuild), + renderChunk: gateHook(renderChunk, () => isWorkerBuild), + } as Plugin; +} diff --git a/packages/remix/src/vite/types.ts b/packages/remix/src/vite/types.ts index df8d487ab445..cf12a0fb3372 100644 --- a/packages/remix/src/vite/types.ts +++ b/packages/remix/src/vite/types.ts @@ -8,4 +8,15 @@ export type SentryRemixVitePluginOptions = { * @example '/absolute/path/to/app' */ appDirPath?: string; + + /** + * Build-time instrumentation of server-side dependencies (e.g. `mysql`, `ioredis`, + * `@remix-run/server-runtime`): the plugin injects `diagnostics_channel` publishers into the + * bundled SSR output, so the SDK traces them without monkey-patching. + * + * Set to `false` to opt out. + * + * @default true + */ + buildTimeInstrumentation?: boolean; }; diff --git a/packages/remix/test/vite/index.test.ts b/packages/remix/test/vite/index.test.ts new file mode 100644 index 000000000000..6fb83053e376 --- /dev/null +++ b/packages/remix/test/vite/index.test.ts @@ -0,0 +1,113 @@ +import type { ConfigEnv, UserConfig } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryRemixVitePlugin } from '../../src/vite'; + +// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in), +// mirroring the real plugin's two shapes. +const orchestrionConfig = vi.fn((_config: UserConfig, env: ConfigEnv) => + env.command === 'serve' ? null : { ssr: { noExternal: ['mysql'] } }, +); +const orchestrionConfigResolved = vi.fn(); +const orchestrionTransform = vi.fn(() => ({ code: 'transformed' })); + +const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => + options?.buildTimeInstrumentation === false + ? { name: 'sentry-orchestrion-disabled' } + : { + name: 'code-transformer', + enforce: 'pre', + config: orchestrionConfig, + configResolved: orchestrionConfigResolved, + transform: orchestrionTransform, + }, +); + +vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ + sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), +})); + +const NODE_CONFIG = { ssr: { target: 'node' } } as UserConfig; +const WORKER_CONFIG = { ssr: { target: 'webworker' } } as UserConfig; +const BUILD_ENV = { command: 'build', mode: 'production' } as ConfigEnv; +const SERVE_ENV = { command: 'serve', mode: 'development' } as ConfigEnv; + +/** Calls a hook declared in either the bare-function or the `{ handler }` form. */ +function callHook(hook: unknown, ...args: unknown[]): unknown { + const handler = typeof hook === 'function' ? hook : (hook as { handler: (...a: unknown[]) => unknown }).handler; + return (handler as (...a: unknown[]) => unknown)(...args); +} + +describe('sentryRemixVitePlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the route manifest plugin and the orchestrion plugin', () => { + const plugins = sentryRemixVitePlugin(); + + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); + }); + + it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', () => { + const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false }); + + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: false }); + expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-disabled'); + }); + + it('keeps the upstream `enforce: "pre"` but defers its `config` hook to the end', () => { + const orchestrion = sentryRemixVitePlugin()[1] as { enforce?: string; config?: { order?: string } }; + + expect(orchestrion.enforce).toBe('pre'); + expect(orchestrion.config?.order).toBe('post'); + }); + + // The dev server keeps instrumented deps external and lets the runtime `--import` hook inject the + // channels, which the orchestrion plugin decides from `env.command` — so the wrapper has to pass + // it through untouched. + it('forwards the config env so the plugin can opt out in the dev server', () => { + const orchestrion = sentryRemixVitePlugin()[1]!; + + expect(callHook(orchestrion.config, NODE_CONFIG, SERVE_ENV)).toBeNull(); + expect(orchestrionConfig).toHaveBeenCalledWith(NODE_CONFIG, SERVE_ENV); + }); + + describe('worker targets', () => { + it('applies the orchestrion hooks for node builds', () => { + const orchestrion = sentryRemixVitePlugin()[1]!; + + expect(callHook(orchestrion.config, NODE_CONFIG, BUILD_ENV)).toEqual({ ssr: { noExternal: ['mysql'] } }); + + callHook(orchestrion.configResolved, NODE_CONFIG); + expect(orchestrionConfigResolved).toHaveBeenCalledTimes(1); + + expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toEqual({ code: 'transformed' }); + }); + + it('skips force-bundling and transforming for webworker builds', () => { + const orchestrion = sentryRemixVitePlugin()[1]!; + + expect(callHook(orchestrion.config, WORKER_CONFIG, BUILD_ENV)).toBeNull(); + expect(orchestrionConfig).not.toHaveBeenCalled(); + + callHook(orchestrion.configResolved, WORKER_CONFIG); + expect(orchestrionConfigResolved).not.toHaveBeenCalled(); + + expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toBeNull(); + expect(orchestrionTransform).not.toHaveBeenCalled(); + }); + + // Framework plugins can set `ssr.target` after our `config` hook ran, so `configResolved` is + // what actually has to keep the transform out of a worker bundle. + it('skips the transform when the worker target only shows up in the resolved config', () => { + const orchestrion = sentryRemixVitePlugin()[1]!; + + callHook(orchestrion.config, {} as UserConfig, BUILD_ENV); + callHook(orchestrion.configResolved, WORKER_CONFIG); + + expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toBeNull(); + expect(orchestrionTransform).not.toHaveBeenCalled(); + }); + }); +}); From 45154a7b70fb871b4b7e0391c21036204ea844e4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 3 Sep 2026 11:30:54 +0200 Subject: [PATCH 2/2] fix(remix): Detect workerd builds by resolve conditions too Remix's own Vite plugin never sets `ssr.target`, so checking it alone missed Cloudflare Pages apps: `cloudflareDevProxyVitePlugin` marks the runtime with `ssr.resolve.externalConditions` and the Cloudflare template sets `ssr.resolve.conditions`. Those builds would have had `node:diagnostics_channel` injected into a bundle workerd cannot load. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0189LWkpCsFqEwM5EFoT11TJ --- packages/remix/src/vite/orchestrionPlugin.ts | 15 ++++++++++++++- packages/remix/test/vite/index.test.ts | 14 +++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/remix/src/vite/orchestrionPlugin.ts b/packages/remix/src/vite/orchestrionPlugin.ts index 5c330166c12f..26df048242d9 100644 --- a/packages/remix/src/vite/orchestrionPlugin.ts +++ b/packages/remix/src/vite/orchestrionPlugin.ts @@ -6,14 +6,27 @@ type AnyHook = (this: unknown, ...args: never[]) => unknown; type ObjectHook = T | { order?: 'pre' | 'post' | null; handler: T }; type ConfigHook = (this: unknown, config: UserConfig, env: ConfigEnv) => unknown; +const WORKER_RESOLVE_CONDITIONS = ['workerd', 'worker']; + /** * Cloudflare Pages and Hydrogen/Oxygen builds instrument through `instrumentBuild()` from * `@sentry/remix/cloudflare`, which wraps the build instead of subscribing to diagnostics * channels. Transforming there would add a `node:diagnostics_channel` import and subscriber code * that nothing reads. + * + * Oxygen sets `ssr.target`, but Remix's own Vite plugin never does — a Cloudflare app is marked by + * workerd resolve conditions instead (`cloudflareDevProxyVitePlugin` sets `externalConditions`, + * the Cloudflare template sets `conditions`), so all three signals have to be checked. */ function isWorkerTarget(config: UserConfig | ResolvedConfig | undefined): boolean { - return config?.ssr?.target === 'webworker'; + const ssr = config?.ssr; + if (ssr?.target === 'webworker') { + return true; + } + + return [ssr?.resolve?.conditions, ssr?.resolve?.externalConditions].some(conditions => + conditions?.some(condition => WORKER_RESOLVE_CONDITIONS.includes(condition)), + ); } function hookHandler(hook: ObjectHook | undefined): T | undefined { diff --git a/packages/remix/test/vite/index.test.ts b/packages/remix/test/vite/index.test.ts index 6fb83053e376..efaff6ddda83 100644 --- a/packages/remix/test/vite/index.test.ts +++ b/packages/remix/test/vite/index.test.ts @@ -28,6 +28,14 @@ vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ const NODE_CONFIG = { ssr: { target: 'node' } } as UserConfig; const WORKER_CONFIG = { ssr: { target: 'webworker' } } as UserConfig; +// Remix's own Vite plugin never sets `ssr.target`, so a Cloudflare app is only recognizable by its +// workerd resolve conditions: `cloudflareDevProxyVitePlugin` contributes `externalConditions`, the +// Cloudflare template sets `conditions` in the user's own config. +const WORKER_CONFIGS: Array<[string, UserConfig]> = [ + ['ssr.target', WORKER_CONFIG], + ['ssr.resolve.conditions', { ssr: { resolve: { conditions: ['workerd', 'worker', 'browser'] } } } as UserConfig], + ['ssr.resolve.externalConditions', { ssr: { resolve: { externalConditions: ['workerd', 'worker'] } } } as UserConfig], +]; const BUILD_ENV = { command: 'build', mode: 'production' } as ConfigEnv; const SERVE_ENV = { command: 'serve', mode: 'development' } as ConfigEnv; @@ -85,13 +93,13 @@ describe('sentryRemixVitePlugin', () => { expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toEqual({ code: 'transformed' }); }); - it('skips force-bundling and transforming for webworker builds', () => { + it.each(WORKER_CONFIGS)('skips force-bundling and transforming when %s marks a worker', (_signal, config) => { const orchestrion = sentryRemixVitePlugin()[1]!; - expect(callHook(orchestrion.config, WORKER_CONFIG, BUILD_ENV)).toBeNull(); + expect(callHook(orchestrion.config, config, BUILD_ENV)).toBeNull(); expect(orchestrionConfig).not.toHaveBeenCalled(); - callHook(orchestrion.configResolved, WORKER_CONFIG); + callHook(orchestrion.configResolved, config); expect(orchestrionConfigResolved).not.toHaveBeenCalled(); expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toBeNull();