diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 396413b64939..c83126e96756 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -7,6 +7,7 @@ import { createRequire } from 'module'; import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; +import { SERVER_INIT_ID, SERVER_INIT_MIDDLEWARE_ID, sentryServerInitPlugin } from './serverInit'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; import type { SentryOptions } from './types'; @@ -174,8 +175,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } + let pathToServerInit: string | undefined; + if (sdkEnabled.server) { - const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); + pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); @@ -183,9 +186,6 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit)); - } else if (!usesCloudflareVitePlugin) { - debug && logger.info('Using default server init.'); - injectScript('page-ssr', buildServerSnippet(options || {})); } if (isCloudflareWorkers && command !== 'dev') { @@ -231,10 +231,36 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const isSSR = config && (config.output === 'server' || config.output === 'hybrid' || !!config.adapter); const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false; + const isNetlify = config?.adapter?.name?.startsWith('@astrojs/netlify'); + const initServerInMiddleware = isSSR && shouldAddMiddleware && !isCloudflareWorkers && !isNetlify; + + if (sdkEnabled.server && !pathToServerInit && !usesCloudflareVitePlugin) { + debug && logger.info('Using default server init.'); + + injectScript( + 'page-ssr', + initServerInMiddleware ? `import "${SERVER_INIT_ID}";` : buildServerSnippet(options || {}), + ); + } + if (isSSR && shouldAddMiddleware) { + if (initServerInMiddleware) { + updateConfig({ + vite: { + plugins: [ + sentryServerInitPlugin( + pathToServerInit + ? buildSdkInitFileImportSnippet(pathToServerInit) + : buildServerSnippet(options || {}), + ), + ], + }, + }); + } + addMiddleware({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: initServerInMiddleware ? SERVER_INIT_MIDDLEWARE_ID : '@sentry/astro/middleware', }); } }, diff --git a/packages/astro/src/integration/serverInit.ts b/packages/astro/src/integration/serverInit.ts new file mode 100644 index 000000000000..9171a6f08b67 --- /dev/null +++ b/packages/astro/src/integration/serverInit.ts @@ -0,0 +1,28 @@ +import type { VitePlugin } from './cloudflare'; + +export const SERVER_INIT_ID = 'virtual:sentry-astro/server-init'; +export const SERVER_INIT_MIDDLEWARE_ID = 'virtual:sentry-astro/server-init-middleware'; + +const RESOLVED_ID_PREFIX = '\0'; + +export function sentryServerInitPlugin(serverInitCode: string): VitePlugin { + return { + name: 'sentry-astro-server-init', + + resolveId(id) { + return id === SERVER_INIT_ID || id === SERVER_INIT_MIDDLEWARE_ID ? `${RESOLVED_ID_PREFIX}${id}` : undefined; + }, + + load(id) { + if (id === `${RESOLVED_ID_PREFIX}${SERVER_INIT_ID}`) { + return serverInitCode; + } + + if (id === `${RESOLVED_ID_PREFIX}${SERVER_INIT_MIDDLEWARE_ID}`) { + return `import "${SERVER_INIT_ID}";\nexport { onRequest } from '@sentry/astro/middleware';`; + } + + return undefined; + }, + }; +} diff --git a/packages/astro/test/integration/index.files.test.ts b/packages/astro/test/integration/index.files.test.ts index 7df9f16ac0ca..52db82364764 100644 --- a/packages/astro/test/integration/index.files.test.ts +++ b/packages/astro/test/integration/index.files.test.ts @@ -35,4 +35,22 @@ describe('sentryAstro integration', () => { expect.stringMatching(/^import ".*\/sentry.server.config.js"/), ); }); + + it('imports the default server init file from the middleware entrypoint', async () => { + const addMiddleware = vi.fn(); + const updateConfig = vi.fn(); + const setup = sentryAstro({}).hooks['astro:config:setup'] as (params: unknown) => Promise; + + await setup({ updateConfig, injectScript, addMiddleware, config: { output: 'server' } }); + + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + + const plugin = updateConfig.mock.calls + .flatMap(([updatedConfig]) => updatedConfig.vite?.plugins ?? []) + .find((vitePlugin: { name?: string }) => vitePlugin?.name === 'sentry-astro-server-init'); + expect(plugin.load('\0virtual:sentry-astro/server-init')).toMatch(/^import ".*\/sentry.server.config.js";$/); + }); }); diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 42e9af789247..b0b84e256b6c 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -680,7 +680,220 @@ describe('sentryAstro integration', () => { expect(addMiddleware).toHaveBeenCalledTimes(1); expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + }); + + describe('server init in the middleware', () => { + const setupWithMiddleware = async (options: SentryOptions, astroConfig: Record) => { + const addMiddleware = vi.fn(); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + const setup = sentryAstro(options).hooks['astro:config:setup'] as (params: unknown) => Promise; + + await setup({ + config: astroConfig, + addMiddleware, + updateConfig, + injectScript, + logger: baseConfigHookObject.logger, + }); + + const plugin = updateConfig.mock.calls + .flatMap(([updatedConfig]) => updatedConfig.vite?.plugins ?? []) + .find((vitePlugin: { name?: string }) => vitePlugin?.name === 'sentry-astro-server-init'); + + return { addMiddleware, injectScript, plugin }; + }; + + it('adds a middleware entrypoint that imports the init file before the Sentry middleware', async () => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js' }, + { output: 'server' }, + ); + + expect(addMiddleware).toHaveBeenCalledTimes(1); + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + + expect(plugin).toBeDefined(); + expect(plugin.resolveId('virtual:sentry-astro/server-init-middleware')).toBe( + '\0virtual:sentry-astro/server-init-middleware', + ); + expect(plugin.resolveId('virtual:sentry-astro/server-init')).toBe('\0virtual:sentry-astro/server-init'); + expect(plugin.resolveId('@sentry/astro/middleware')).toBeUndefined(); + expect(plugin.load('\0virtual:sentry-astro/server-init-middleware')).toBe( + 'import "virtual:sentry-astro/server-init";\nexport { onRequest } from \'@sentry/astro/middleware\';', + ); + expect(plugin.load('\0virtual:sentry-astro/server-init')).toMatch(/^import ".*\/my-server-init-path.js";$/); + expect(plugin.load('some-other-module')).toBeUndefined(); + + expect(injectScript).toHaveBeenCalledWith( + 'page-ssr', + expect.stringMatching(/^import ".*\/my-server-init-path.js"/), + ); + }); + + it('keeps the default middleware entrypoint for the cloudflare workers adapter', async () => { + const { addMiddleware, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js' }, + { output: 'server', adapter: { name: '@astrojs/cloudflare' } }, + ); + + expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', entrypoint: '@sentry/astro/middleware' }); + expect(plugin).toBeUndefined(); + }); + + it("doesn't add the middleware entrypoint if the middleware is disabled by users", async () => { + const { addMiddleware, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js', autoInstrumentation: { requestHandler: false } }, + { output: 'server' }, + ); + + expect(addMiddleware).not.toHaveBeenCalled(); + expect(plugin).toBeUndefined(); + }); + + it.each(['server', 'hybrid'])('uses the init middleware entrypoint in %s mode', async output => { + const { addMiddleware, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js' }, + { output }, + ); + + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + expect(plugin).toBeDefined(); + }); + + it("doesn't add the middleware entrypoint if the server SDK is disabled", async () => { + const { addMiddleware, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js', enabled: { client: true, server: false } }, + { output: 'server' }, + ); + + expect(addMiddleware).not.toHaveBeenCalled(); + expect(plugin).toBeUndefined(); + }); + + it('initializes the SDK with the default snippet once, from a module shared by page-ssr and the middleware', async () => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware({}, { output: 'server' }); + + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + expect(injectScript.mock.calls.filter(([target]) => target === 'page-ssr')).toHaveLength(1); + expect(injectScript).toHaveBeenCalledWith('page-ssr', 'import "virtual:sentry-astro/server-init";'); + + const initCode: string = plugin.load('\0virtual:sentry-astro/server-init'); + const middlewareCode: string = plugin.load('\0virtual:sentry-astro/server-init-middleware'); + expect(initCode.match(/Sentry\.init\(/g)).toHaveLength(1); + expect(middlewareCode).not.toContain('Sentry.init('); + expect(middlewareCode).toContain('import "virtual:sentry-astro/server-init";'); + }); + + it('does not put a Sentry.init into the virtual modules when a server init file is used', async () => { + const { plugin } = await setupWithMiddleware({ serverInitPath: 'my-server-init-path.js' }, { output: 'server' }); + + expect(plugin.load('\0virtual:sentry-astro/server-init')).not.toContain('Sentry.init('); + expect(plugin.load('\0virtual:sentry-astro/server-init-middleware')).not.toContain('Sentry.init('); + }); + + it('passes the build options to the default snippet in the virtual module', async () => { + const { plugin } = await setupWithMiddleware({ debug: true, release: { name: '1.0.0' } }, { output: 'server' }); + + const code: string = plugin.load('\0virtual:sentry-astro/server-init'); + expect(code).toContain('release: "1.0.0"'); + expect(code).toContain('debug: true'); + }); + + it('keeps the default snippet in page-ssr for the cloudflare workers adapter', async () => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware( + {}, + { output: 'server', adapter: { name: '@astrojs/cloudflare' } }, + ); + + expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', entrypoint: '@sentry/astro/middleware' }); + expect(plugin).toBeUndefined(); + expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('Sentry.init')); + }); + + it.each([ + ['a server init file', { serverInitPath: 'my-server-init-path.js' }], + ['the default snippet', {}], + ])('uses the init middleware entrypoint for the cloudflare pages adapter with %s', async (_, options) => { + wranglerPagesConfig = '{ "pages_build_output_dir": "./dist" }'; + + try { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware(options, { + output: 'server', + adapter: { name: '@astrojs/cloudflare' }, + }); + + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + expect(plugin).toBeDefined(); + expect(injectScript).toHaveBeenCalledWith( + 'page-ssr', + 'serverInitPath' in options + ? expect.stringMatching(/^import ".*\/my-server-init-path.js"/) + : 'import "virtual:sentry-astro/server-init";', + ); + } finally { + wranglerPagesConfig = undefined; + } + }); + + it.each([ + ['a server init file', { serverInitPath: 'my-server-init-path.js' }, /^import ".*\/my-server-init-path.js";$/], + ['the default snippet', {}, /^import \* as Sentry from "@sentry\/astro";[\s\S]*Sentry\.init\(/], + ])('keeps the previous setup for the netlify adapter with %s', async (_, options, pageSsrScript) => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware(options, { + output: 'server', + adapter: { name: '@astrojs/netlify' }, + }); + + expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', entrypoint: '@sentry/astro/middleware' }); + expect(plugin).toBeUndefined(); + expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringMatching(pageSsrScript)); + }); + + it('uses the init middleware entrypoint when only an adapter is configured', async () => { + const { addMiddleware, plugin } = await setupWithMiddleware( + { serverInitPath: 'my-server-init-path.js' }, + { adapter: { name: '@astrojs/node' } }, + ); + + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: 'virtual:sentry-astro/server-init-middleware', + }); + expect(plugin).toBeDefined(); + }); + + it('keeps the default snippet in page-ssr if the middleware is disabled by users', async () => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware( + { autoInstrumentation: { requestHandler: false } }, + { output: 'server' }, + ); + + expect(addMiddleware).not.toHaveBeenCalled(); + expect(plugin).toBeUndefined(); + expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('Sentry.init')); + }); + + it('keeps the default snippet in page-ssr in static mode', async () => { + const { addMiddleware, injectScript, plugin } = await setupWithMiddleware({}, {}); + + expect(addMiddleware).not.toHaveBeenCalled(); + expect(plugin).toBeUndefined(); + expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('Sentry.init')); }); });