From 0b37df2cb931af67e4967ae324014f5f4fd0a82d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 25 Sep 2026 12:04:50 +0200 Subject: [PATCH] fix(sveltekit): Resolve `@opentelemetry/api` via the SDK on SvelteKit 3 SvelteKit 3.0.0-next.28 externalizes `@opentelemetry/api` in server builds, so the Kit runtime imports it at runtime from the app root. Under pnpm or Yarn PnP that only resolves if the app depends on it directly, so enabling `tracing.server` made every request fail. The `sentrySvelteKit()` plugin now redirects that import to a new `@sentry/sveltekit/opentelemetry-api` re-export. It resolves because the app always depends on the SDK directly, and it stays external, so the Kit runtime and `instrumentation.server.js` still share one instance. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/sveltekit/package.json | 6 ++ packages/sveltekit/rollup.npm.config.mjs | 1 + packages/sveltekit/src/opentelemetryApi.ts | 3 + .../sveltekit/src/vite/sentryVitePlugins.ts | 44 +++++++++++- .../test/vite/sentrySvelteKitPlugins.test.ts | 69 +++++++++++++++++-- 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 packages/sveltekit/src/opentelemetryApi.ts diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 68b608262b02..74e180506c91 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -46,6 +46,11 @@ "require": "./build/cjs/index.server.js" } }, + "./opentelemetry-api": { + "types": "./build/types/opentelemetryApi.d.ts", + "import": "./build/esm/opentelemetryApi.js", + "require": "./build/cjs/opentelemetryApi.js" + }, "./vite": { "types": "./build/types/vite/index.d.ts", "import": "./build/esm/vite/index.js", @@ -65,6 +70,7 @@ } }, "dependencies": { + "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.0.0", "@sentry/core": "11.0.0", "@sentry/conventions": "^0.24.0", diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index 331f396b7075..b6da582e41fc 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -15,6 +15,7 @@ export default makeNPMConfigVariants( 'src/server/index.ts', 'src/worker/index.ts', 'src/vite/index.ts', + 'src/opentelemetryApi.ts', ], packageSpecificConfig: { // Keep the variant subpath external so the transpiled output preserves the import for the diff --git a/packages/sveltekit/src/opentelemetryApi.ts b/packages/sveltekit/src/opentelemetryApi.ts new file mode 100644 index 000000000000..904712f1699e --- /dev/null +++ b/packages/sveltekit/src/opentelemetryApi.ts @@ -0,0 +1,3 @@ +// Re-exported under `@sentry/sveltekit/opentelemetry-api` so the `sentrySvelteKit()` plugin can point +// SvelteKit's `@opentelemetry/api` import here. See `makeOpenTelemetryApiResolverPlugin`. +export * from '@opentelemetry/api'; diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index ede0aab0db89..a390e3abf2ba 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -52,7 +52,11 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} // First so the config settles as early as possible. The plugins below read it in `configResolved`, // which Vite runs concurrently, so their order relative to the resolver doesn't matter. - const sentryPlugins: Plugin[] = [kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin()]; + const sentryPlugins: Plugin[] = [ + kitConfigResolver.plugin, + makeBrowserTracingVariantResolverPlugin(), + makeOpenTelemetryApiResolverPlugin(), + ]; if (mergedOptions.autoInstrument) { const pluginOptions: AutoInstrumentSelection = { @@ -144,6 +148,44 @@ function makeBrowserTracingVariantResolverPlugin(): Plugin { }; } +const OTEL_API_ID = '@opentelemetry/api'; +const OTEL_API_REEXPORT_ID = '@sentry/sveltekit/opentelemetry-api'; +const OTEL_API_REEXPORT_FILE_REGEX = /[\\/]opentelemetryApi\.js$/; + +/** + * SvelteKit 3 externalizes `@opentelemetry/api` in server builds, so its runtime imports it at runtime + * from the app root. That only resolves if the app depends on it directly: under pnpm (or Yarn PnP), + * the copy installed through `@sentry/sveltekit` isn't reachable, and Kit fails every request once + * `tracing.server` is enabled. + * + * We redirect the import to our re-export instead, which resolves because the app always depends on + * `@sentry/sveltekit` directly. It stays external, so the Kit runtime and `instrumentation.server.js` + * still share a single module instance, which is why Kit externalizes it in the first place. + */ +function makeOpenTelemetryApiResolverPlugin(): Plugin { + let isKit3Promise: Promise | undefined; + + return { + name: 'sentry-sveltekit-opentelemetry-api', + enforce: 'pre', + // In dev, Kit's import resolves from Kit's own install location, where pnpm links the peer dep. + apply: 'build', + async resolveId(id, importer, options) { + // Skip our own re-export, in case it gets bundled, so it doesn't re-export itself. + if (id !== OTEL_API_ID || !options?.ssr || (importer && OTEL_API_REEXPORT_FILE_REGEX.test(importer))) { + return null; + } + + isKit3Promise ??= isSvelteKit3(id => this.resolve(id, undefined, { skipSelf: true })); + if (!(await isKit3Promise)) { + return null; + } + + return { id: OTEL_API_REEXPORT_ID, external: true }; + }, + }; +} + /** * Whether to use the SvelteKit 3 (`$app/state`) variant, from the installed `@sveltejs/kit` version * (resolved via the bundler, not `process.cwd()`). diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 8a4c22597c2c..5221b323d056 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { Plugin } from 'vite'; import { describe, expect, it, vi } from 'vitest'; import * as autoInstrument from '../../src/vite/autoInstrument'; @@ -64,10 +67,11 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins(); expect(plugins).toBeInstanceOf(Array); - // 1 kit config resolver + 1 browser-tracing variant resolver + 1 auto instrument plugin + // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver + // + 1 auto instrument plugin // + 1 orchestrion plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(9); + expect(plugins).toHaveLength(10); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -78,6 +82,8 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-kit-config-resolver', // browser-tracing variant resolver: 'sentry-sveltekit-browser-tracing-variant', + // OpenTelemetry API resolver: + 'sentry-sveltekit-opentelemetry-api', // auto instrument plugin: 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: @@ -95,7 +101,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(4); // kit config resolver + browser-tracing variant resolver + auto instrument + orchestrion + expect(plugins).toHaveLength(5); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -103,9 +109,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[2]; + const instrumentPlugin = plugins[3]; - expect(plugins).toHaveLength(5); // kit config resolver + browser-tracing variant resolver + auto instrument + orchestrion + global values injection + expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -114,7 +120,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(9); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -226,7 +232,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[2]!; + const plugin = plugins[3]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ @@ -238,6 +244,55 @@ describe('sentrySvelteKit()', () => { }); }); +describe('OpenTelemetry API resolver plugin', () => { + async function getResolver(kitVersion: string) { + const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); + const plugin = plugins.find(p => p.name === 'sentry-sveltekit-opentelemetry-api')!; + + const kitPackageJson = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-kit-')), 'package.json'); + fs.writeFileSync(kitPackageJson, JSON.stringify({ version: kitVersion })); + const context = { resolve: vi.fn().mockResolvedValue({ id: kitPackageJson }) }; + + const resolveId = (id: string, importer: string | undefined, ssr = true) => + // @ts-expect-error - minimal plugin context + plugin.resolveId.call(context, id, importer, { ssr }); + + return { plugin, resolveId }; + } + + it('only applies to builds', async () => { + const { plugin } = await getResolver('3.0.0'); + expect(plugin.apply).toBe('build'); + }); + + it('redirects `@opentelemetry/api` to the external re-export on SvelteKit 3', async () => { + const { resolveId } = await getResolver('3.0.0-next.28'); + + await expect(resolveId('@opentelemetry/api', '/app/node_modules/@sveltejs/kit/src/instance.js')).resolves.toEqual({ + id: '@sentry/sveltekit/opentelemetry-api', + external: true, + }); + }); + + it('leaves `@opentelemetry/api` alone on SvelteKit 2', async () => { + const { resolveId } = await getResolver('2.70.2'); + + await expect(resolveId('@opentelemetry/api', '/app/src/instrumentation.server.js')).resolves.toBeNull(); + }); + + it('leaves client builds, other ids and the re-export itself alone', async () => { + const { resolveId } = await getResolver('3.0.0'); + + await expect(resolveId('@opentelemetry/api', '/app/src/hooks.client.js', false)).resolves.toBeNull(); + await expect( + resolveId('@opentelemetry/api/experimental', '/app/src/instrumentation.server.js'), + ).resolves.toBeNull(); + await expect( + resolveId('@opentelemetry/api', '/app/node_modules/@sentry/sveltekit/build/esm/opentelemetryApi.js'), + ).resolves.toBeNull(); + }); +}); + describe('generateVitePluginOptions', () => { it('returns null if no relevant options are provided', () => { const options: SentrySvelteKitPluginOptions = {};