diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs index 8f620f57591f..0894958462b2 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/astro.config.mjs @@ -2,6 +2,7 @@ import sentry from '@sentry/astro'; // @ts-check import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; // https://astro.build/config export default defineConfig({ @@ -18,4 +19,7 @@ export default defineConfig({ allowedDomains: [{ hostname: 'localhost' }], }, adapter: cloudflare(), + vite: { + plugins: [sentryCloudflareVitePlugin()], + }, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js new file mode 100644 index 000000000000..eeb325d68c3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js @@ -0,0 +1,9 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions(env => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server + debug: true, +})); diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.server.config.js deleted file mode 100644 index 8f1a839bc05f..000000000000 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.server.config.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as Sentry from '@sentry/cloudflare'; -import handler from '@astrojs/cloudflare/entrypoints/server'; - -export default Sentry.withSentry( - env => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server - debug: true, - }), - handler, -); diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/worker-configuration.d.ts index 03c757e9dcc2..96d062b4c7ac 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/worker-configuration.d.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/worker-configuration.d.ts @@ -5,7 +5,7 @@ interface __BaseEnv_Env { } declare namespace Cloudflare { interface GlobalProps { - mainModule: typeof import('./sentry.server.config'); + mainModule: typeof import('@astrojs/cloudflare/entrypoints/server'); } interface Env extends __BaseEnv_Env {} } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/wrangler.jsonc index eed026ecf585..ff0744ef24d8 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/wrangler.jsonc @@ -2,7 +2,7 @@ "compatibility_date": "2026-03-12", "compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"], "name": "astro-6-cf-workers", - "main": "./sentry.server.config.js", + "main": "@astrojs/cloudflare/entrypoints/server", "assets": { "directory": "./dist", "binding": "ASSETS", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore deleted file mode 100644 index 3f64191fe034..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -.wrangler -test-results -pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts deleted file mode 100644 index 10d0f17fbcdb..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineCloudflareOptions } from '@sentry/cloudflare'; - -// The worker entry is a package, so the plugin reads this file from beside -// the wrangler config. -export default defineCloudflareOptions((env: { E2E_TEST_DSN: string }) => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1.0, -})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json deleted file mode 100644 index 2211b7f3455b..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "cloudflare-autoinstrument-package-entry", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "test": "playwright test", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "worker-entry": "file:../../test-applications/cloudflare-autoinstrument-package-entry/worker-entry" - }, - "devDependencies": { - "@cloudflare/vite-plugin": "^1.47.0", - "@cloudflare/workers-types": "^5.20260727.1", - "@playwright/test": "~1.63.0", - "@sentry/conventions": "0.27.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "vite": "^8.1.5", - "wrangler": "^4.114.0" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts deleted file mode 100644 index 9633de63b272..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -export default getPlaywrightConfig( - { - startCommand: 'pnpm preview', - port: 8787, - }, - { - workers: '100%', - retries: 0, - }, -); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs deleted file mode 100644 index 553dac81dc3c..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'cloudflare-autoinstrument-package-entry', -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts deleted file mode 100644 index 7baf4af55892..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { SENTRY_ENVIRONMENT, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; - -// The worker entry is a package with no Sentry calls. Any event that arrives -// proves the plugin wrapped the module that wrangler's `main` resolved to. - -test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { - const spanPromise = waitForStreamedSpan( - 'cloudflare-autoinstrument-package-entry', - span => getSpanOp(span) === HTTP_SERVER && span.is_segment, - ); - - const res = await fetch(baseURL!); - expect(res.status).toBe(200); - - const span = await spanPromise; - expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare'); - // Only `instrument.server.ts` sets this environment. - expect(span.attributes[SENTRY_ENVIRONMENT]?.value).toBe('qa'); -}); - -test('captures an error thrown by a package entry', async ({ baseURL }) => { - const errorPromise = waitForError( - 'cloudflare-autoinstrument-package-entry', - event => event.exception?.values?.[0]?.value === 'Thrown from a package entry', - ); - - const res = await fetch(`${baseURL}/throw`); - expect(res.status).toBe(500); - - const error = await errorPromise; - expect(error.environment).toBe('qa'); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts deleted file mode 100644 index 005f4448f6cb..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { cloudflare } from '@cloudflare/vite-plugin'; -import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [cloudflare(), sentryCloudflareVitePlugin()], -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js deleted file mode 100644 index 1d8a976634e6..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js +++ /dev/null @@ -1,11 +0,0 @@ -// Shaped like bundler output (e.g. `@astrojs/cloudflare`): the default export -// is a specifier, not `export default `. -var fetch_handler_default = { - async fetch(request) { - if (new URL(request.url).pathname === '/throw') { - throw new Error('Thrown from a package entry'); - } - return new Response('ok'); - }, -}; -export { fetch_handler_default as default }; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json deleted file mode 100644 index 08018b205e6e..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "worker-entry", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - "./fetch-handler": "./fetch-handler.js" - } -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc deleted file mode 100644 index 4f9bc84616ee..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "cloudflare-autoinstrument-package-entry", - // A package export, not a file path. The Sentry plugin resolves it with Vite. - "main": "worker-entry/fetch-handler", - "compatibility_date": "2026-05-20", - "compatibility_flags": ["nodejs_compat"], -} diff --git a/packages/astro/src/integration/cloudflare.ts b/packages/astro/src/integration/cloudflare.ts index 93311a735248..69e7ff7be99e 100644 --- a/packages/astro/src/integration/cloudflare.ts +++ b/packages/astro/src/integration/cloudflare.ts @@ -37,6 +37,10 @@ export function sentryCloudflareNodeWarningPlugin(): VitePlugin { }; } +// The Worker entry module: `@astrojs/cloudflare` v12 builds its own virtual entry, and v13+ builds +// through `@cloudflare/vite-plugin`, whose entry ends in `export default mod.default ?? {};`. +const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry']; + /** * A Vite plugin that ensures the Sentry server config is loaded at the * top level of the Cloudflare Worker entry module, rather than only being @@ -57,16 +61,14 @@ export function sentryCloudflareVitePlugin(): VitePlugin { enforce: 'post', transform(code, id) { - // Match the Astro SSR virtual entry — this becomes dist/_worker.js/index.js - // The resolved virtual module ID is `\0@astrojs-ssr-virtual-entry` - if (!id.includes('astrojs-ssr-virtual-entry')) { + if (!WORKER_ENTRY_IDS.some(entryId => id.includes(entryId))) { return undefined; } // In @astrojs/cloudflare v12, the virtual entry module structure is: // https://github.com/withastro/astro/blob/09bbdbb1e62c388eb405eeea03554c15e01f2957/packages/integrations/cloudflare/src/entrypoints/server.ts#L23 // We need to wrap `default` with `withSentry` before it's exported. - const defaultExportMatch = code.match(/export\s+default\s+([\w.]+)\s*;/); + const defaultExportMatch = code.match(/export\s+default\s+([^;]+?)\s*;/); if (!defaultExportMatch) { return undefined; diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index b4e54545a8d3..396413b64939 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -142,6 +142,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const isCloudflare = config?.adapter?.name?.startsWith('@astrojs/cloudflare'); const isCloudflareWorkers = isCloudflare && !isCloudflarePages(); + // That plugin adds its own orchestrion transform and wraps the Worker entry, which inits the + // SDK from `instrument.server.*` or `env`. With its `autoInstrumentation: false`, the user's + // own `withSentry` does the init. Either way, none of ours are needed. + const usesCloudflareVitePlugin = isCloudflareWorkers && hasCloudflareVitePlugin(config); // Wire up the orchestrion code transform so instrumented server-side dependencies (e.g. // `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at @@ -149,7 +153,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // `buildTimeInstrumentation` is `false`. Cloudflare Pages is skipped: it gets no // `withSentry` wrap, so nothing would read the marker the injected snippets write, and // keeping the transform off avoids bundling dead subscriber code. - if (sdkEnabled.server && (!isCloudflare || isCloudflareWorkers)) { + if (sdkEnabled.server && (!isCloudflare || isCloudflareWorkers) && !usesCloudflareVitePlugin) { updateConfig({ vite: { plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin], @@ -179,7 +183,7 @@ 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 { + } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); injectScript('page-ssr', buildServerSnippet(options || {})); } @@ -199,7 +203,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { debug && logger.info('Adding Cloudflare Vite plugin to wrap Worker entry with withSentry.'); updateConfig({ vite: { - plugins: [sentryCloudflareNodeWarningPlugin(), sentryCloudflareVitePlugin()], + plugins: usesCloudflareVitePlugin + ? [sentryCloudflareNodeWarningPlugin()] + : [sentryCloudflareNodeWarningPlugin(), sentryCloudflareVitePlugin()], }, }); } else if (isCloudflare) { @@ -236,6 +242,32 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }; }; +// Every sub-plugin of `sentryCloudflareVitePlugin()` but its orchestrion transform has this prefix, +// and some are added whatever its options are. +const CLOUDFLARE_VITE_PLUGIN_NAME_PREFIX = 'sentry-cloudflare-'; + +/** + * Whether the user added `sentryCloudflareVitePlugin()` from `@sentry/cloudflare/vite`. It is + * detected by name because importing it here would load `@sentry/cloudflare` and `wrangler` for + * every Astro app. + */ +function hasCloudflareVitePlugin(config: AstroConfig): boolean { + const pending: unknown[] = [config.vite?.plugins]; + while (pending.length > 0) { + const plugin = pending.pop(); + if (Array.isArray(plugin)) { + pending.push(...plugin); + } else if ( + typeof plugin === 'object' && + plugin !== null && + String((plugin as { name?: unknown }).name).startsWith(CLOUDFLARE_VITE_PLUGIN_NAME_PREFIX) + ) { + return true; + } + } + return false; +} + const possibleFileExtensions = ['ts', 'js', 'tsx', 'jsx', 'mjs', 'cjs', 'mts']; function findDefaultSdkInitFile(type: 'server' | 'client'): string | undefined { diff --git a/packages/astro/test/integration/cloudflareVitePlugin.test.ts b/packages/astro/test/integration/cloudflareVitePlugin.test.ts new file mode 100644 index 000000000000..0464acd321e2 --- /dev/null +++ b/packages/astro/test/integration/cloudflareVitePlugin.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { sentryCloudflareVitePlugin } from '../../src/integration/cloudflare'; + +function transform(code: string, id: string) { + const plugin = sentryCloudflareVitePlugin() as { transform: (code: string, id: string) => unknown }; + return plugin.transform(code, id); +} + +describe('sentryCloudflareVitePlugin', () => { + it('wraps the default export of the @astrojs/cloudflare v12 virtual entry', () => { + const code = ['const _exports = createExports(manifest);', 'export default _exports.default;'].join('\n'); + + expect(transform(code, '\0@astrojs-ssr-virtual-entry')).toEqual({ + code: [ + "import { withSentry } from '@sentry/cloudflare';", + 'const _exports = createExports(manifest);', + 'export default withSentry(() => undefined, _exports.default);', + ].join('\n'), + map: null, + }); + }); + + it('wraps the default export of the @cloudflare/vite-plugin worker entry', () => { + const code = [ + 'import { getExportTypes } from "virtual:cloudflare/export-types";', + 'import * as mod from "virtual:cloudflare/user-entry";', + 'export * from "virtual:cloudflare/user-entry";', + 'export default mod.default ?? {};', + 'if (import.meta.hot) { import.meta.hot.accept(); }', + ].join('\n'); + + expect(transform(code, '\0virtual:cloudflare/worker-entry')).toEqual({ + code: [ + "import { withSentry } from '@sentry/cloudflare';", + 'import { getExportTypes } from "virtual:cloudflare/export-types";', + 'import * as mod from "virtual:cloudflare/user-entry";', + 'export * from "virtual:cloudflare/user-entry";', + 'export default withSentry(() => undefined, mod.default ?? {});', + 'if (import.meta.hot) { import.meta.hot.accept(); }', + ].join('\n'), + map: null, + }); + }); + + it('leaves other modules alone', () => { + expect(transform('export default handler;', '/src/pages/index.astro')).toBeUndefined(); + }); +}); diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 54b199086c7c..42e9af789247 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -451,6 +451,81 @@ describe('sentryAstro integration', () => { }); }); + // The plugin returns an array, so it arrives nested. + describe.each([ + { + setup: 'sentryCloudflareVitePlugin()', + plugins: [ + { name: 'code-transformer' }, + { name: 'sentry-cloudflare-mastra-observability-provider' }, + { name: 'sentry-cloudflare-flue-runtime-provider' }, + { name: 'sentry-cloudflare-auto-instrument' }, + ], + }, + { + setup: 'sentryCloudflareVitePlugin({ autoInstrumentation: false })', + plugins: [ + { name: 'code-transformer' }, + { name: 'sentry-cloudflare-mastra-observability-provider' }, + { name: 'sentry-cloudflare-flue-runtime-provider' }, + ], + }, + ])('with `$setup` from `@sentry/cloudflare/vite` in the vite config', ({ plugins }) => { + const cloudflareConfig = { + ...config, + adapter: { name: '@astrojs/cloudflare' }, + vite: { plugins: [plugins] }, + } as AstroConfig; + + it('leaves the orchestrion transform and the Worker wrap to that plugin', async () => { + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + updateConfig, + injectScript, + config: cloudflareConfig, + }); + + expect(orchestrionVite).not.toHaveBeenCalled(); + expect(updateConfig).toHaveBeenCalledWith({ + vite: { plugins: [expect.objectContaining({ name: 'sentry-astro-cloudflare-suppress-node-warnings' })] }, + }); + expect(updateConfig).not.toHaveBeenCalledWith({ + vite: { plugins: expect.arrayContaining([expect.objectContaining({ name: 'sentry-astro-cloudflare' })]) }, + }); + }); + + it('does not inject the default server init', async () => { + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + updateConfig, + injectScript, + config: cloudflareConfig, + }); + + expect(injectScript).not.toHaveBeenCalledWith('page-ssr', expect.anything()); + }); + + it('still injects a server init file', async () => { + const integration = sentryAstro({ serverInitPath: 'my-server-init-path.js' }); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + updateConfig, + injectScript, + config: cloudflareConfig, + }); + + expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('my-server-init-path.js')); + }); + }); + it("doesn't add the orchestrion plugin for the cloudflare pages adapter", async () => { // Simulate a Pages project: a wrangler config containing `pages_build_output_dir`. wranglerPagesConfig = '{ "pages_build_output_dir": "./dist" }';