From cde6cd2cd694e83f3cb7c066b43efebb2258f84a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 27 Oct 2026 10:34:32 -0600 Subject: [PATCH 1/2] fix(cloudflare): Run build-time instrumentation in vite dev `@cloudflare/vite-plugin` pre-bundles the worker dependencies in `vite dev`, and the Vite dependency optimizer runs no Vite plugins. So the orchestrion transform never ran on instrumented packages, and their spans were missing. The orchestrion Vite plugin now adds the transform to `optimizeDeps` of server environments in `serve`: a Rolldown plugin on Vite 8 and an esbuild plugin on Vite 6 and 7. Both resolve the snippet import to the ESM entry of `@sentry/server-utils`, like the build does. The cloudflare variant of the `hono-4` e2e app now also runs its tests against `vite dev`. Co-Authored-By: Claude Opus 5.5 --- dev-packages/e2e-tests/README.md | 6 + .../test-applications/hono-4/package.json | 4 +- .../hono-4/playwright.config.ts | 6 +- .../src/orchestrion/bundler/vite.ts | 144 +++++++++++++----- .../test/orchestrion/bundler.test.ts | 136 +++++++++++++++-- .../test/orchestrion/viteDepOptimizer.test.ts | 60 ++++++++ 6 files changed, 311 insertions(+), 45 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index d71ba9b1b8a3..967f76eb72a5 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -187,6 +187,12 @@ is the reference setup. because workerd does not allow it. `nextjs-16` has no Vite build: its Cloudflare variant builds with `opennextjs-cloudflare build --config wrangler.opennext.jsonc` and wraps the generated Worker in `worker.ts` with `withSentry` from `@sentry/nextjs/cloudflare`. +- **Cloudflare in `vite dev`**: to run the Worker in `vite dev` (for example the dev tests of the `hono-4` Cloudflare + variant), start it with `CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev`. The runner gives `E2E_TEST_DSN` to the app + only as a process env var. `wrangler dev` adds it to the Worker `env` with `--var "E2E_TEST_DSN:$E2E_TEST_DSN"`, but + `@cloudflare/vite-plugin` has no such option. It fills the Worker `env` from the `vars` of the wrangler config and + from `.dev.vars` or `.env`, and adds process env vars only when `CLOUDFLARE_INCLUDE_PROCESS_ENV` is `true`. Without + it, `env.E2E_TEST_DSN` is `undefined`, the SDK sends no events, and the tests fail. - **Build flavors**: a variant can also build the same app with another tool, for example `nextjs-16 (vinext)` builds `nextjs-16` with vinext. A build flavor is not a runtime: its assert script sets its own env var (`VINEXT=1`, read by `tests/isVinext.ts`). It sets `runtime` only to run on another runtime, for example `nextjs-16 (vinext, bun)`. diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 82f47a8b7368..d1aca0e29ced 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -8,14 +8,16 @@ "dev:bun": "bun run dist/entry.bun.js", "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "dev:cloudflare:vite": "CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev", "test:build": "pnpm install && pnpm typecheck", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", "test:assert": "pnpm test:assert:node", "test:assert:node": "RUNTIME=node pnpm test", "test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test", "test:assert:deno": "RUNTIME=deno pnpm test", - "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test" + "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test && RUNTIME=cloudflare pnpm test:dev" }, "dependencies": { "@sentry/bun": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 475e082970af..5b40a1bd48a0 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -10,7 +10,11 @@ if (!testEnv) { const APP_PORT = 8787; const startCommands: Record = { - cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`, + // `vite dev` pre-bundles `hono` with its Rolldown dependency optimizer, so this also tests the transform there. + cloudflare: + testEnv === 'development' + ? `pnpm dev:cloudflare:vite --port ${APP_PORT} --strictPort` + : `pnpm dev:cloudflare --port ${APP_PORT}`, node: `pnpm dev:node`, bun: `pnpm dev:bun`, deno: `pnpm dev:deno`, diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 16e642dc76fd..aaab9e935581 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,14 +1,17 @@ +import esbuildCodeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin as EsbuildPlugin } from 'esbuild'; +import type { ConfigEnv, ConfigPluginContext, EnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; export type { Plugin as VitePlugin } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; import { createExternalRequireResolver } from './externalRequire'; -import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve'; +import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER, SNIPPET_IMPORT_SPECIFIER_FILTER } from './resolve'; type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; +type ResolveIdHandler = Extract, (...args: never[]) => unknown>; // Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()` // call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved` @@ -17,17 +20,13 @@ const INSTANCE_KEY = '__sentryOrchestrionInstance'; type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object }; -// 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'], isDuplicate: () => boolean): Plugin['transform'] { +function gateTransform( + transform: Plugin['transform'], + skip: (opts?: { ssr?: boolean }) => boolean, +): Plugin['transform'] { const gate = (handler: TransformHandler): TransformHandler => function (code, id, opts) { - if (!opts?.ssr || isDuplicate()) { + if (skip(opts)) { return null; } return handler.call(this, code, id, opts); @@ -47,6 +46,74 @@ function toEsmEntry(id: string): string { return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js'); } +// 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 +// normal resolution fails, fall back to this package's own resolution so it +// gets bundled from its real on-disk path. +// +// Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, +// which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external +// packages into default imports, and `@sentry/core` has no default export, so the server +// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. +async function resolveSnippetImport( + this: ThisParameterType, + ...[source, importer, resolveOptions]: Parameters +): Promise>> { + const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); + if (resolved) { + return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; + } + const fallback = resolveOrchestrionRuntimeRequest(source); + return fallback ? toEsmEntry(fallback) : null; +} + +// Rolldown plugin for the dependency optimizer of Vite 8. +function depOptimizerRolldownPlugin(options: PluginOptions, isDuplicate: () => boolean): Plugin { + const upstream = codeTransformer(orchestrionTransformOptions(options)); + return { + ...upstream, + name: 'sentry-orchestrion-dep-optimizer', + transform: gateTransform(upstream.transform, isDuplicate), + resolveId(source, importer, resolveOptions) { + return source === SNIPPET_IMPORT_SPECIFIER + ? resolveSnippetImport.call(this, source, importer, resolveOptions) + : null; + }, + }; +} + +// esbuild plugin for the dependency optimizer of Vite 6 and 7. +function depOptimizerEsbuildPlugin(options: PluginOptions, isDuplicate: () => boolean): EsbuildPlugin { + const upstream = esbuildCodeTransformer(orchestrionTransformOptions(options)); + return { + name: 'sentry-orchestrion-dep-optimizer', + setup(build) { + if (isDuplicate()) { + return; + } + // Same resolution as `resolveSnippetImport`. The `pluginData` marker stops the recursion back into this callback. + build.onResolve({ filter: SNIPPET_IMPORT_SPECIFIER_FILTER }, async args => { + if (args.pluginData === 'sentry-orchestrion-resolving') { + return null; + } + const resolved = await build.resolve(args.path, { + resolveDir: args.resolveDir, + importer: args.importer, + kind: args.kind, + pluginData: 'sentry-orchestrion-resolving', + }); + if (resolved.errors.length === 0) { + return resolved.external ? resolved : { ...resolved, path: toEsmEntry(resolved.path) }; + } + const fallback = resolveOrchestrionRuntimeRequest(args.path); + return fallback ? { path: toEsmEntry(fallback) } : null; + }); + return upstream.setup(build); + }, + }; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -83,31 +150,20 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { const plugin: OrchestrionVitePlugin = { ...upstream, [INSTANCE_KEY]: instance, - 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 - // normal resolution fails, fall back to this package's own resolution so it - // gets bundled from its real on-disk path. SSR-gated like the transform: the - // specifier only appears in SSR modules. - // - // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, - // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external - // packages into default imports, and `@sentry/core` has no default export, so the server - // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. + // 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. + transform: gateTransform(upstream.transform, opts => !opts?.ssr || isDuplicate), + // SSR-gated like the transform: the specifier only appears in SSR modules. async resolveId(source, importer, resolveOptions) { if (source !== SNIPPET_IMPORT_SPECIFIER) { return resolveExternalRequire.call(this, source, importer, resolveOptions); } - if (!resolveOptions?.ssr) { - return null; - } - const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); - if (resolved) { - return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; - } - const fallback = resolveOrchestrionRuntimeRequest(source); - return fallback ? toEsmEntry(fallback) : null; + return resolveOptions?.ssr ? resolveSnippetImport.call(this, source, importer, resolveOptions) : null; }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only @@ -136,14 +192,34 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { }, }, configEnvironment( + // Vite 6 calls this hook without a context. + this: ConfigPluginContext | undefined, name: string, config: { consumer?: 'client' | 'server' }, env?: ConfigEnv, - ): { resolve: { noExternal: string[] } } | null { - if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { + ): EnvironmentOptions | null { + if ((config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { return null; } + // Some server environments pre-bundle their dependencies in `serve`, for example the ones + // of `@cloudflare/vite-plugin`. The dependency optimizer runs no Vite plugins, so the + // transform has to run inside it. + if (env?.command === 'serve') { + if (this?.meta?.rolldownVersion) { + return { + optimizeDeps: { rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicate)] } }, + }; + } + return { + optimizeDeps: { + // Vite 6 and 7 have no `rolldownOptions`. + // oxlint-disable-next-line typescript/no-deprecated + esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicate)] }, + }, + }; + } + return { resolve: { noExternal: noExternalModules() } }; }, configResolved(config: ResolvedConfig): void { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b0690ab74903..570836afd1b5 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; -import type { OnStartResult, PluginBuild } from 'esbuild'; +import type { OnResolveArgs, OnResolveResult, OnStartResult, Plugin as EsbuildPlugin, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; -import type { ResolvedConfig } from 'vite'; +import type { ResolvedConfig, Plugin as VitePlugin } from 'vite'; import type { Compiler } from 'webpack'; import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as bunPlugin } from '../../src/orchestrion/bundler/bun'; @@ -245,18 +245,28 @@ describe('sentryOrchestrionPlugin (vite)', () => { return config.handler.call(plugin, userConfig, { command, mode: 'production' }); } + type EnvironmentConfigResult = { + resolve?: { noExternal: string[] }; + optimizeDeps?: { + esbuildOptions?: { plugins: EsbuildPlugin[] }; + rolldownOptions?: { plugins: VitePlugin[] }; + }; + } | null; + function runConfigEnvironment( name: string, environmentConfig: Record, command: 'build' | 'serve' = 'build', - ): { resolve: { noExternal: string[] } } | null { - const plugin = vitePlugin(); + // Vite 6 passes no context, Vite 8 passes `rolldownVersion`. + context?: { meta: { rolldownVersion?: string } }, + plugin = vitePlugin(), + ): EnvironmentConfigResult { const configEnvironment = plugin.configEnvironment as ( name: string, config: unknown, env: unknown, - ) => { resolve: { noExternal: string[] } } | null; - return configEnvironment.call(plugin, name, environmentConfig, { command, mode: 'production' }); + ) => EnvironmentConfigResult; + return configEnvironment.call(context, name, environmentConfig, { command, mode: 'production' }); } it('warns when instrumented modules are listed in ssr.external', () => { @@ -286,7 +296,115 @@ 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(); + expect(runConfigEnvironment('ssr', {}, 'serve')?.resolve).toBeUndefined(); + }); + + it('runs the transform in the dependency optimizer of server environments on the dev server', () => { + expect(runConfigEnvironment('ssr', {}, 'serve')).toEqual({ + optimizeDeps: { + esbuildOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, + }, + }); + expect( + runConfigEnvironment('worker', { consumer: 'server' }, 'serve', { meta: { rolldownVersion: '1.2.14' } }), + ).toEqual({ + optimizeDeps: { + rolldownOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, + }, + }); + expect(runConfigEnvironment('client', {}, 'serve')).toBeNull(); + expect( + runConfigEnvironment('browser', { consumer: 'client' }, 'serve', { meta: { rolldownVersion: '1.2.14' } }), + ).toBeNull(); + }); + + it('transforms in the Rolldown dependency optimizer without the ssr flag, only in the first instance', () => { + const first = vitePlugin(); + const second = vitePlugin(); + const config = { + command: 'serve', + plugins: [first, second], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + const context = { meta: { rolldownVersion: '1.2.14' } }; + const firstOptimizerPlugin = runConfigEnvironment('worker', {}, 'serve', context, first)?.optimizeDeps + ?.rolldownOptions?.plugins[0]; + const secondOptimizerPlugin = runConfigEnvironment('worker', {}, 'serve', context, second)?.optimizeDeps + ?.rolldownOptions?.plugins[0]; + + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstTransform = firstOptimizerPlugin?.transform as (this: unknown, code: string, id: string) => unknown; + const secondTransform = secondOptimizerPlugin?.transform as (this: unknown, code: string, id: string) => unknown; + expect(firstTransform.call({}, 'code', 'id')).toBe('transformed'); + expect(secondTransform.call({}, 'code', 'id')).toBeNull(); + }); + + it('resolves the snippet import to the ESM entry in the Rolldown dependency optimizer', async () => { + const optimizerPlugin = runConfigEnvironment('worker', {}, 'serve', { meta: { rolldownVersion: '1.2.14' } }) + ?.optimizeDeps?.rolldownOptions?.plugins[0]; + const resolveId = optimizerPlugin?.resolveId as ( + this: unknown, + source: string, + importer: string, + opts: { kind: string }, + ) => unknown; + const resolve = vi.fn().mockResolvedValue({ id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js' }); + + await expect( + resolveId.call({ resolve }, '@sentry/server-utils', '/app/node_modules/mysql/index.js', { kind: 'require-call' }), + ).resolves.toEqual({ id: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + expect(resolveId.call({ resolve }, 'mysql', '/app/src/index.ts', { kind: 'import-statement' })).toBeNull(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('resolves the snippet import to the ESM entry in the esbuild dependency optimizer', async () => { + const optimizerPlugin = runConfigEnvironment('ssr', {}, 'serve')?.optimizeDeps?.esbuildOptions?.plugins[0]; + const onResolve = vi.fn(); + const resolve = vi.fn().mockResolvedValue({ + errors: [], + path: '/app/node_modules/@sentry/server-utils/build/cjs/index.js', + }); + await optimizerPlugin?.setup({ onResolve, resolve } as unknown as PluginBuild); + + expect(onResolve).toHaveBeenCalledWith({ filter: /^@sentry\/server-utils$/ }, expect.any(Function)); + const callback = onResolve.mock.lastCall?.[1] as (args: Partial) => Promise; + await expect( + callback({ path: '@sentry/server-utils', importer: '/app/node_modules/mysql/index.js', kind: 'require-call' }), + ).resolves.toEqual({ errors: [], path: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + + // When it fails (pnpm isolation), fall back to this package's own resolution. + resolve.mockResolvedValueOnce({ errors: [{ text: 'Could not resolve' }] }); + const fallback = await callback({ path: '@sentry/server-utils', importer: '/x.js', kind: 'require-call' }); + expect(fallback?.path).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); + }); + + it('sets up the esbuild dependency optimizer plugin only in the first instance', async () => { + const first = vitePlugin(); + const second = vitePlugin(); + const config = { + command: 'serve', + plugins: [first, second], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + const firstOptimizerPlugin = runConfigEnvironment('ssr', {}, 'serve', undefined, first)?.optimizeDeps + ?.esbuildOptions?.plugins[0]; + const secondOptimizerPlugin = runConfigEnvironment('ssr', {}, 'serve', undefined, second)?.optimizeDeps + ?.esbuildOptions?.plugins[0]; + + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstOnResolve = vi.fn(); + const secondOnResolve = vi.fn(); + await firstOptimizerPlugin?.setup({ onResolve: firstOnResolve } as unknown as PluginBuild); + await secondOptimizerPlugin?.setup({ onResolve: secondOnResolve } as unknown as PluginBuild); + + expect(firstOnResolve).toHaveBeenCalledTimes(1); + expect(secondOnResolve).not.toHaveBeenCalled(); }); it('adds the top-level ssr option only when the config already has ssr or build.ssr', () => { @@ -296,10 +414,10 @@ describe('sentryOrchestrionPlugin (vite)', () => { }); it('force-bundles instrumented modules in server environments', () => { - expect(runConfigEnvironment('ssr', {})?.resolve.noExternal).toEqual( + expect(runConfigEnvironment('ssr', {})?.resolve?.noExternal).toEqual( expect.arrayContaining(['mysql', '@sentry/server-utils']), ); - expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve.noExternal).toContain('mysql'); + expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve?.noExternal).toContain('mysql'); expect(runConfigEnvironment('client', {})).toBeNull(); expect(runConfigEnvironment('browser', { consumer: 'client' })).toBeNull(); }); diff --git a/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts new file mode 100644 index 000000000000..9f84c8a97efd --- /dev/null +++ b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { InstrumentationConfig } from '../../src/orchestrion/apmTypes'; +import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/vite'; + +const instrumentations: InstrumentationConfig[] = [ + { + channelName: 'query', + module: { name: 'fake-db', versionRange: '>=1.0.0', filePath: 'index.js' }, + functionQuery: { functionName: 'query', kind: 'Sync' }, + }, +]; + +describe('sentryOrchestrionPlugin (vite) in the Rolldown dependency optimizer', () => { + let root: string; + let server: ViteDevServer; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'orchestrion-dep-optimizer-')); + const packageDir = join(root, 'node_modules', 'fake-db'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'fake-db', version: '1.0.0', type: 'module' }), + ); + writeFileSync(join(packageDir, 'index.js'), 'export function query(sql) {\n return sql;\n}\n'); + + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [sentryOrchestrionPlugin({ instrumentations })], + server: { middlewareMode: true, ws: false, watch: null }, + environments: { + ssr: { + optimizeDeps: { include: ['fake-db'], rolldownOptions: { external: ['@sentry/server-utils'] } }, + }, + }, + }); + await server.environments.ssr.depsOptimizer?.init(); + }); + + afterAll(async () => { + await server.close(); + rmSync(root, { recursive: true, force: true }); + }); + + it('transforms a pre-bundled dependency and maps it only to its file in node_modules', () => { + const file = server.environments.ssr.depsOptimizer?.metadata.optimized['fake-db']?.file; + expect(file).toBeDefined(); + + expect(readFileSync(file!, 'utf8')).toContain('orchestrion:fake-db:query'); + const map = JSON.parse(readFileSync(`${file}.map`, 'utf8')) as { sources: string[] }; + // `node_modules/.vite/deps_ssr/fake-db.js` -> `node_modules/fake-db/index.js` + expect(map.sources).toEqual(['../../fake-db/index.js']); + }); +}); From 76f66b42342759c4df0dcc58d2c0364a005f1dff Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 27 Oct 2026 16:55:25 -0600 Subject: [PATCH 2/2] fixup! fix(cloudflare): Run build-time instrumentation in vite dev --- packages/cloudflare/src/vite/index.ts | 1 + .../src/orchestrion/bundler/options.ts | 7 ++ .../src/orchestrion/bundler/vite.ts | 32 ++++-- .../test/orchestrion/bundler.test.ts | 98 +++++++++++++------ .../test/orchestrion/viteDepOptimizer.test.ts | 2 +- 5 files changed, 99 insertions(+), 41 deletions(-) diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index d6005250d9ee..a0a4b91fecdc 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -90,6 +90,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp // Route the injected `tracingChannel` imports through the workerd-safe façade so a // dependency wrapped at module scope (`const app = new Hono()`) doesn't throw on workerd. dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel', + transformOptimizedDeps: true, }), sentryMastraObservabilityProviderPlugin(), sentryFlueRuntimeProviderPlugin(), diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index ad6439cd5962..0d9aec844d59 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -36,6 +36,13 @@ export type PluginOptions = { * routing every channel through it is harmless. */ dcModule?: string; + /** + * When `true`, the Vite plugin also runs the transform in the dependency optimizer of server environments in + * `vite dev`. Needed for `@cloudflare/vite-plugin`, which pre-bundles the dependencies of a Worker there. + * + * @default false + */ + transformOptimizedDeps?: boolean; }; /** diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index aaab9e935581..d1a40295c9ee 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -18,7 +18,9 @@ type ResolveIdHandler = Extract, (...args: neve // can find the first instance in a build also through a wrapper. const INSTANCE_KEY = '__sentryOrchestrionInstance'; -type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object }; +type OrchestrionInstance = { transformOptimizedDeps: boolean }; + +type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: OrchestrionInstance }; function gateTransform( transform: Plugin['transform'], @@ -143,9 +145,12 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { ]; const resolveExternalRequire = createExternalRequireResolver(noExternalModules()); - const instance = {}; + const instance: OrchestrionInstance = { transformOptimizedDeps: options.transformOptimizedDeps === true }; // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. let isDuplicate = false; + // `true` when an earlier instance with `transformOptimizedDeps` is in the same build, which then transforms the + // pre-bundled dependencies alone. + let isDuplicateOptimizer = false; const plugin: OrchestrionVitePlugin = { ...upstream, @@ -202,20 +207,25 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return null; } - // Some server environments pre-bundle their dependencies in `serve`, for example the ones - // of `@cloudflare/vite-plugin`. The dependency optimizer runs no Vite plugins, so the - // transform has to run inside it. + // `@cloudflare/vite-plugin` pre-bundles the dependencies of a Worker in `serve`. The dependency + // optimizer runs no Vite plugins, so the transform has to run inside it. A Node server loads its + // dependencies with Node, where the runtime hook instruments them. if (env?.command === 'serve') { + if (!options.transformOptimizedDeps) { + return null; + } if (this?.meta?.rolldownVersion) { return { - optimizeDeps: { rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicate)] } }, + optimizeDeps: { + rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicateOptimizer)] }, + }, }; } return { optimizeDeps: { // Vite 6 and 7 have no `rolldownOptions`. // oxlint-disable-next-line typescript/no-deprecated - esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicate)] }, + esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicateOptimizer)] }, }, }; } @@ -225,8 +235,12 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { 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. - const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]); - isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance; + const instances = (config.plugins as readonly OrchestrionVitePlugin[]).flatMap(p => p[INSTANCE_KEY] ?? []); + isDuplicate = instances[0] !== undefined && instances[0] !== instance; + // The first instance can be one without `transformOptimizedDeps`, for example the one of `sentryReactRouter` + // before `sentryCloudflareVitePlugin`. + const firstOptimizer = instances.find(i => i.transformOptimizedDeps); + isDuplicateOptimizer = firstOptimizer !== undefined && firstOptimizer !== instance; // Nothing is force-bundled in `serve`, so an externalized module is expected there. if (isDuplicate || config.command === 'serve') { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 570836afd1b5..33abf914aa74 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -256,10 +256,16 @@ describe('sentryOrchestrionPlugin (vite)', () => { function runConfigEnvironment( name: string, environmentConfig: Record, - command: 'build' | 'serve' = 'build', - // Vite 6 passes no context, Vite 8 passes `rolldownVersion`. - context?: { meta: { rolldownVersion?: string } }, - plugin = vitePlugin(), + { + command = 'build', + context, + plugin = vitePlugin(), + }: { + command?: 'build' | 'serve'; + // Vite 6 passes no context, Vite 8 passes `rolldownVersion`. + context?: { meta: { rolldownVersion?: string } }; + plugin?: VitePlugin; + } = {}, ): EnvironmentConfigResult { const configEnvironment = plugin.configEnvironment as ( name: string, @@ -296,43 +302,60 @@ 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')?.resolve).toBeUndefined(); + expect( + runConfigEnvironment('ssr', {}, { command: 'serve', plugin: vitePlugin({ transformOptimizedDeps: true }) }) + ?.resolve, + ).toBeUndefined(); + }); + + it('does not change the dependency optimizer on the dev server without transformOptimizedDeps', () => { + // A Node server loads its dependencies with Node, where the runtime hook instruments them. + expect(runConfigEnvironment('ssr', {}, { command: 'serve' })).toBeNull(); + expect( + runConfigEnvironment( + 'worker', + { consumer: 'server' }, + { command: 'serve', context: { meta: { rolldownVersion: '1.2.14' } } }, + ), + ).toBeNull(); }); - it('runs the transform in the dependency optimizer of server environments on the dev server', () => { - expect(runConfigEnvironment('ssr', {}, 'serve')).toEqual({ + it('runs the transform in the dependency optimizer of server environments with transformOptimizedDeps', () => { + const plugin = vitePlugin({ transformOptimizedDeps: true }); + const context = { meta: { rolldownVersion: '1.2.14' } }; + + expect(runConfigEnvironment('ssr', {}, { command: 'serve', plugin })).toEqual({ optimizeDeps: { esbuildOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, }, }); - expect( - runConfigEnvironment('worker', { consumer: 'server' }, 'serve', { meta: { rolldownVersion: '1.2.14' } }), - ).toEqual({ + expect(runConfigEnvironment('worker', { consumer: 'server' }, { command: 'serve', context, plugin })).toEqual({ optimizeDeps: { rolldownOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, }, }); - expect(runConfigEnvironment('client', {}, 'serve')).toBeNull(); - expect( - runConfigEnvironment('browser', { consumer: 'client' }, 'serve', { meta: { rolldownVersion: '1.2.14' } }), - ).toBeNull(); + expect(runConfigEnvironment('client', {}, { command: 'serve', plugin })).toBeNull(); + expect(runConfigEnvironment('browser', { consumer: 'client' }, { command: 'serve', context, plugin })).toBeNull(); }); - it('transforms in the Rolldown dependency optimizer without the ssr flag, only in the first instance', () => { - const first = vitePlugin(); - const second = vitePlugin(); + it('transforms in the Rolldown dependency optimizer without the ssr flag, only in the first instance with transformOptimizedDeps', () => { + // For example the instance of `sentryReactRouter` before the one of `sentryCloudflareVitePlugin`. + const other = vitePlugin(); + const first = vitePlugin({ transformOptimizedDeps: true }); + const second = vitePlugin({ transformOptimizedDeps: true }); const config = { command: 'serve', - plugins: [first, second], + plugins: [other, first, second], ssr: {}, logger: { warn: vi.fn() }, } as unknown as ResolvedConfig; const context = { meta: { rolldownVersion: '1.2.14' } }; - const firstOptimizerPlugin = runConfigEnvironment('worker', {}, 'serve', context, first)?.optimizeDeps - ?.rolldownOptions?.plugins[0]; - const secondOptimizerPlugin = runConfigEnvironment('worker', {}, 'serve', context, second)?.optimizeDeps - ?.rolldownOptions?.plugins[0]; + const firstOptimizerPlugin = runConfigEnvironment('worker', {}, { command: 'serve', context, plugin: first }) + ?.optimizeDeps?.rolldownOptions?.plugins[0]; + const secondOptimizerPlugin = runConfigEnvironment('worker', {}, { command: 'serve', context, plugin: second }) + ?.optimizeDeps?.rolldownOptions?.plugins[0]; + (other.configResolved as (config: unknown) => void)(config); (first.configResolved as (config: unknown) => void)(config); (second.configResolved as (config: unknown) => void)(config); @@ -343,8 +366,15 @@ describe('sentryOrchestrionPlugin (vite)', () => { }); it('resolves the snippet import to the ESM entry in the Rolldown dependency optimizer', async () => { - const optimizerPlugin = runConfigEnvironment('worker', {}, 'serve', { meta: { rolldownVersion: '1.2.14' } }) - ?.optimizeDeps?.rolldownOptions?.plugins[0]; + const optimizerPlugin = runConfigEnvironment( + 'worker', + {}, + { + command: 'serve', + context: { meta: { rolldownVersion: '1.2.14' } }, + plugin: vitePlugin({ transformOptimizedDeps: true }), + }, + )?.optimizeDeps?.rolldownOptions?.plugins[0]; const resolveId = optimizerPlugin?.resolveId as ( this: unknown, source: string, @@ -361,7 +391,11 @@ describe('sentryOrchestrionPlugin (vite)', () => { }); it('resolves the snippet import to the ESM entry in the esbuild dependency optimizer', async () => { - const optimizerPlugin = runConfigEnvironment('ssr', {}, 'serve')?.optimizeDeps?.esbuildOptions?.plugins[0]; + const optimizerPlugin = runConfigEnvironment( + 'ssr', + {}, + { command: 'serve', plugin: vitePlugin({ transformOptimizedDeps: true }) }, + )?.optimizeDeps?.esbuildOptions?.plugins[0]; const onResolve = vi.fn(); const resolve = vi.fn().mockResolvedValue({ errors: [], @@ -381,20 +415,22 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(fallback?.path).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); }); - it('sets up the esbuild dependency optimizer plugin only in the first instance', async () => { - const first = vitePlugin(); - const second = vitePlugin(); + it('sets up the esbuild dependency optimizer plugin only in the first instance with transformOptimizedDeps', async () => { + const other = vitePlugin(); + const first = vitePlugin({ transformOptimizedDeps: true }); + const second = vitePlugin({ transformOptimizedDeps: true }); const config = { command: 'serve', - plugins: [first, second], + plugins: [other, first, second], ssr: {}, logger: { warn: vi.fn() }, } as unknown as ResolvedConfig; - const firstOptimizerPlugin = runConfigEnvironment('ssr', {}, 'serve', undefined, first)?.optimizeDeps + const firstOptimizerPlugin = runConfigEnvironment('ssr', {}, { command: 'serve', plugin: first })?.optimizeDeps ?.esbuildOptions?.plugins[0]; - const secondOptimizerPlugin = runConfigEnvironment('ssr', {}, 'serve', undefined, second)?.optimizeDeps + const secondOptimizerPlugin = runConfigEnvironment('ssr', {}, { command: 'serve', plugin: second })?.optimizeDeps ?.esbuildOptions?.plugins[0]; + (other.configResolved as (config: unknown) => void)(config); (first.configResolved as (config: unknown) => void)(config); (second.configResolved as (config: unknown) => void)(config); diff --git a/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts index 9f84c8a97efd..d8ca916ede5a 100644 --- a/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts +++ b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts @@ -32,7 +32,7 @@ describe('sentryOrchestrionPlugin (vite) in the Rolldown dependency optimizer', root, configFile: false, logLevel: 'silent', - plugins: [sentryOrchestrionPlugin({ instrumentations })], + plugins: [sentryOrchestrionPlugin({ instrumentations, transformOptimizedDeps: true })], server: { middlewareMode: true, ws: false, watch: null }, environments: { ssr: {