Skip to content

Commit e070d45

Browse files
chargomeclaude
andcommitted
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189LWkpCsFqEwM5EFoT11TJ
1 parent bf5a493 commit e070d45

7 files changed

Lines changed: 199 additions & 9 deletions

File tree

‎MIGRATION.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,8 @@ Affected SDKs: `@sentry/remix`.
12721272
+ import { sentryRemixVitePlugin } from '@sentry/remix/vite';
12731273
```
12741274
1275+
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 })`.
1276+
12751277
## 3. Removed APIs
12761278
12771279
### `@sentry/core` / All SDKs

‎dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
},
1414
"dependencies": {
1515
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
16-
"@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz",
1716
"@remix-run/css-bundle": "2.17.4",
1817
"@remix-run/node": "2.17.4",
1918
"@remix-run/react": "2.17.4",
Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { vitePlugin as remix } from '@remix-run/dev';
22
import { sentryRemixVitePlugin } from '@sentry/remix/vite';
3-
import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
43
import { defineConfig } from 'vite';
54
import tsconfigPaths from 'vite-tsconfig-paths';
65

@@ -10,10 +9,6 @@ export default defineConfig({
109
ignoredRouteFiles: ['**/.*'],
1110
}),
1211
sentryRemixVitePlugin(),
13-
// Run the orchestrion code transform over the SSR server bundle and force-bundle the
14-
// instrumented deps (mysql, ioredis, @remix-run/server-runtime, …) so their
15-
// diagnostics-channel calls are injected at build time.
16-
sentryOrchestrionPlugin(),
1712
tsconfigPaths(),
1813
],
1914
});

‎packages/remix/src/vite/index.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Plugin } from 'vite';
2+
import { makeOrchestrionPlugin } from './orchestrionPlugin';
23
import { makeRouteManifestPlugin } from './routeManifestPlugin';
34
import type { SentryRemixVitePluginOptions } from './types';
45

@@ -7,8 +8,9 @@ export type { SentryRemixVitePluginOptions };
78
/**
89
* Sentry Vite plugins for Remix.
910
*
10-
* Add these to your Vite configuration to inject the Remix route manifest, so client-side
11-
* transactions are parameterized.
11+
* Add these to your Vite configuration to
12+
* - inject the Remix route manifest, so client-side transactions are parameterized, and
13+
* - build-time instrument supported server-side dependencies (such as database clients).
1214
*
1315
* @example
1416
* ```typescript
@@ -28,5 +30,5 @@ export type { SentryRemixVitePluginOptions };
2830
* ```
2931
*/
3032
export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin[] {
31-
return [makeRouteManifestPlugin(options)];
33+
return [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)];
3234
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
2+
import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
3+
import type { SentryRemixVitePluginOptions } from './types';
4+
5+
type AnyHook = (this: unknown, ...args: never[]) => unknown;
6+
type ObjectHook<T> = T | { order?: 'pre' | 'post' | null; handler: T };
7+
type ConfigHook = (this: unknown, config: UserConfig, env: ConfigEnv) => unknown;
8+
9+
/**
10+
* Cloudflare Pages and Hydrogen/Oxygen builds instrument through `instrumentBuild()` from
11+
* `@sentry/remix/cloudflare`, which wraps the build instead of subscribing to diagnostics
12+
* channels. Transforming there would add a `node:diagnostics_channel` import and subscriber code
13+
* that nothing reads.
14+
*/
15+
function isWorkerTarget(config: UserConfig | ResolvedConfig | undefined): boolean {
16+
return config?.ssr?.target === 'webworker';
17+
}
18+
19+
function hookHandler<T extends AnyHook>(hook: ObjectHook<T> | undefined): T | undefined {
20+
return typeof hook === 'function' ? hook : hook?.handler;
21+
}
22+
23+
/** No-ops a hook while `isDisabled()` holds, keeping its declared hook shape. */
24+
function gateHook<T extends AnyHook>(
25+
hook: ObjectHook<T> | undefined,
26+
isDisabled: () => boolean,
27+
): ObjectHook<T> | undefined {
28+
if (!hook) {
29+
return hook;
30+
}
31+
32+
const handler = hookHandler(hook) as AnyHook;
33+
const gated = function (this: unknown, ...args: never[]): unknown {
34+
return isDisabled() ? null : handler.apply(this, args);
35+
} as T;
36+
37+
return typeof hook === 'function' ? gated : { ...hook, handler: gated };
38+
}
39+
40+
/** The orchestrion bundler plugin, wired to stay out of workerd builds. */
41+
export function makeOrchestrionPlugin(options: Pick<SentryRemixVitePluginOptions, 'buildTimeInstrumentation'>): Plugin {
42+
const orchestrion = sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation });
43+
const { renderChunk } = orchestrion as Plugin & { renderChunk?: ObjectHook<AnyHook> };
44+
const config = hookHandler(orchestrion.config as ObjectHook<ConfigHook> | undefined);
45+
const configResolved = hookHandler(orchestrion.configResolved);
46+
47+
let isWorkerBuild = false;
48+
49+
return {
50+
...orchestrion,
51+
// Upstream ships the plugin with `enforce: 'pre'`, which would run this hook before the
52+
// framework plugins that set `ssr.target`. `order: 'post'` moves it after them.
53+
config: {
54+
order: 'post',
55+
handler(userConfig: UserConfig, env: ConfigEnv) {
56+
return isWorkerTarget(userConfig) ? null : (config?.(userConfig, env) ?? null);
57+
},
58+
},
59+
// The authoritative check: the resolved config reflects every plugin regardless of ordering,
60+
// and this always runs before the first `transform`.
61+
configResolved(resolvedConfig: ResolvedConfig) {
62+
isWorkerBuild = isWorkerTarget(resolvedConfig);
63+
return isWorkerBuild ? undefined : configResolved?.(resolvedConfig);
64+
},
65+
transform: gateHook(orchestrion.transform as ObjectHook<AnyHook> | undefined, () => isWorkerBuild),
66+
renderChunk: gateHook(renderChunk, () => isWorkerBuild),
67+
} as Plugin;
68+
}

‎packages/remix/src/vite/types.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,15 @@ export type SentryRemixVitePluginOptions = {
88
* @example '/absolute/path/to/app'
99
*/
1010
appDirPath?: string;
11+
12+
/**
13+
* Build-time instrumentation of server-side dependencies (e.g. `mysql`, `ioredis`,
14+
* `@remix-run/server-runtime`): the plugin injects `diagnostics_channel` publishers into the
15+
* bundled SSR output, so the SDK traces them without monkey-patching.
16+
*
17+
* Set to `false` to opt out.
18+
*
19+
* @default true
20+
*/
21+
buildTimeInstrumentation?: boolean;
1122
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { ConfigEnv, UserConfig } from 'vite';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { sentryRemixVitePlugin } from '../../src/vite';
4+
5+
// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in),
6+
// mirroring the real plugin's two shapes.
7+
const orchestrionConfig = vi.fn((_config: UserConfig, env: ConfigEnv) =>
8+
env.command === 'serve' ? null : { ssr: { noExternal: ['mysql'] } },
9+
);
10+
const orchestrionConfigResolved = vi.fn();
11+
const orchestrionTransform = vi.fn(() => ({ code: 'transformed' }));
12+
13+
const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) =>
14+
options?.buildTimeInstrumentation === false
15+
? { name: 'sentry-orchestrion-disabled' }
16+
: {
17+
name: 'code-transformer',
18+
enforce: 'pre',
19+
config: orchestrionConfig,
20+
configResolved: orchestrionConfigResolved,
21+
transform: orchestrionTransform,
22+
},
23+
);
24+
25+
vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
26+
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
27+
}));
28+
29+
const NODE_CONFIG = { ssr: { target: 'node' } } as UserConfig;
30+
const WORKER_CONFIG = { ssr: { target: 'webworker' } } as UserConfig;
31+
const BUILD_ENV = { command: 'build', mode: 'production' } as ConfigEnv;
32+
const SERVE_ENV = { command: 'serve', mode: 'development' } as ConfigEnv;
33+
34+
/** Calls a hook declared in either the bare-function or the `{ handler }` form. */
35+
function callHook(hook: unknown, ...args: unknown[]): unknown {
36+
const handler = typeof hook === 'function' ? hook : (hook as { handler: (...a: unknown[]) => unknown }).handler;
37+
return (handler as (...a: unknown[]) => unknown)(...args);
38+
}
39+
40+
describe('sentryRemixVitePlugin', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
it('returns the route manifest plugin and the orchestrion plugin', () => {
46+
const plugins = sentryRemixVitePlugin();
47+
48+
expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']);
49+
expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined });
50+
});
51+
52+
it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', () => {
53+
const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false });
54+
55+
expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: false });
56+
expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-disabled');
57+
});
58+
59+
it('keeps the upstream `enforce: "pre"` but defers its `config` hook to the end', () => {
60+
const orchestrion = sentryRemixVitePlugin()[1] as { enforce?: string; config?: { order?: string } };
61+
62+
expect(orchestrion.enforce).toBe('pre');
63+
expect(orchestrion.config?.order).toBe('post');
64+
});
65+
66+
// The dev server keeps instrumented deps external and lets the runtime `--import` hook inject the
67+
// channels, which the orchestrion plugin decides from `env.command` — so the wrapper has to pass
68+
// it through untouched.
69+
it('forwards the config env so the plugin can opt out in the dev server', () => {
70+
const orchestrion = sentryRemixVitePlugin()[1]!;
71+
72+
expect(callHook(orchestrion.config, NODE_CONFIG, SERVE_ENV)).toBeNull();
73+
expect(orchestrionConfig).toHaveBeenCalledWith(NODE_CONFIG, SERVE_ENV);
74+
});
75+
76+
describe('worker targets', () => {
77+
it('applies the orchestrion hooks for node builds', () => {
78+
const orchestrion = sentryRemixVitePlugin()[1]!;
79+
80+
expect(callHook(orchestrion.config, NODE_CONFIG, BUILD_ENV)).toEqual({ ssr: { noExternal: ['mysql'] } });
81+
82+
callHook(orchestrion.configResolved, NODE_CONFIG);
83+
expect(orchestrionConfigResolved).toHaveBeenCalledTimes(1);
84+
85+
expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toEqual({ code: 'transformed' });
86+
});
87+
88+
it('skips force-bundling and transforming for webworker builds', () => {
89+
const orchestrion = sentryRemixVitePlugin()[1]!;
90+
91+
expect(callHook(orchestrion.config, WORKER_CONFIG, BUILD_ENV)).toBeNull();
92+
expect(orchestrionConfig).not.toHaveBeenCalled();
93+
94+
callHook(orchestrion.configResolved, WORKER_CONFIG);
95+
expect(orchestrionConfigResolved).not.toHaveBeenCalled();
96+
97+
expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toBeNull();
98+
expect(orchestrionTransform).not.toHaveBeenCalled();
99+
});
100+
101+
// Framework plugins can set `ssr.target` after our `config` hook ran, so `configResolved` is
102+
// what actually has to keep the transform out of a worker bundle.
103+
it('skips the transform when the worker target only shows up in the resolved config', () => {
104+
const orchestrion = sentryRemixVitePlugin()[1]!;
105+
106+
callHook(orchestrion.config, {} as UserConfig, BUILD_ENV);
107+
callHook(orchestrion.configResolved, WORKER_CONFIG);
108+
109+
expect(callHook(orchestrion.transform, 'code', 'mysql.js', { ssr: true })).toBeNull();
110+
expect(orchestrionTransform).not.toHaveBeenCalled();
111+
});
112+
});
113+
});

0 commit comments

Comments
 (0)