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 = {};