diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.gitignore b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.gitignore new file mode 100644 index 000000000000..560782d47d98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.gitignore @@ -0,0 +1,26 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +test-results diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.npmrc b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs new file mode 100644 index 000000000000..4de1fcb44fc6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/astro.config.mjs @@ -0,0 +1,18 @@ +import cloudflare from '@astrojs/cloudflare'; +import sentry from '@sentry/astro'; +// @ts-check +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + sentry({ + debug: true, + sourceMapsUploadOptions: { + enabled: false, + }, + }), + ], + output: 'server', + adapter: cloudflare(), +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json new file mode 100644 index 000000000000..b74b36c9d314 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -0,0 +1,31 @@ +{ + "name": "astro-5-cf-workers", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "wrangler dev --port 3030", + "test:build": "pnpm install && pnpm build", + "test:assert": "TEST_ENV=production playwright test" + }, + "dependencies": { + "@astrojs/cloudflare": "^12.6.12", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/astro": "latest || *", + "@sentry/cloudflare": "latest || *", + "astro": "^5.17.1" + }, + "devDependencies": { + "wrangler": "^4.63.0" + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.0" + } + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/playwright.config.mjs new file mode 100644 index 000000000000..3cdf5850b613 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/playwright.config.mjs @@ -0,0 +1,14 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig({ + startCommand: 'pnpm preview', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.client.config.js new file mode 100644 index 000000000000..2b79ec0ed337 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.client.config.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.server.config.js new file mode 100644 index 000000000000..2b79ec0ed337 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/sentry.server.config.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/actions/index.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/actions/index.ts new file mode 100644 index 000000000000..47e5386981fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/actions/index.ts @@ -0,0 +1,24 @@ +import { defineAction, ActionError } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + testAction: defineAction({ + input: z.object({ + name: z.string(), + shouldError: z.boolean().optional(), + }), + handler: async input => { + if (input.shouldError) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'Test Action Error', + }); + } + + return { + status: 'success', + name: input.name, + }; + }, + }), +}; diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/layouts/Layout.astro b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/layouts/Layout.astro new file mode 100644 index 000000000000..6105f48ffd35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/layouts/Layout.astro @@ -0,0 +1,22 @@ + + + + + + + + Astro Basics + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/action-test/index.astro b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/action-test/index.astro new file mode 100644 index 000000000000..ba2f876a78da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/action-test/index.astro @@ -0,0 +1,31 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; +--- + + +

Action Test Page

+
+ + +
+
+ + +
diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/api/test-error.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/api/test-error.ts new file mode 100644 index 000000000000..24ac1b4d39ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/api/test-error.ts @@ -0,0 +1,7 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = () => { + throw new Error('This is a test error from an API route'); +}; diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/api.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/api.ts new file mode 100644 index 000000000000..a76accdba010 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/api.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = ({ request, url }) => { + if (url.searchParams.has('error')) { + throw new Error('Endpoint Error'); + } + return new Response( + JSON.stringify({ + search: url.search, + sp: url.searchParams, + }), + ); +}; diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/index.astro b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/index.astro new file mode 100644 index 000000000000..ecfb0641144e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/endpoint-error/index.astro @@ -0,0 +1,9 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; +--- + + + + diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/index.astro b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/index.astro new file mode 100644 index 000000000000..90a5b300a178 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/index.astro @@ -0,0 +1,15 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+

Astro CF Workers E2E Test App

+ +
+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/ssr-error/index.astro b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/ssr-error/index.astro new file mode 100644 index 000000000000..fc42bcbae4f7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/src/pages/ssr-error/index.astro @@ -0,0 +1,11 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +const a = {} as any; +console.log(a.foo.x); +export const prerender = false; +--- + + +

Page with SSR error

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..335219f1f1d4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'astro-5-cf-workers', +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/actions.test.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/actions.test.ts new file mode 100644 index 000000000000..2a964941217a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/actions.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Astro actions', () => { + test('captures transaction for action call', async ({ page }) => { + const transactionEventPromise = waitForTransaction('astro-5-cf-workers', transactionEvent => { + return transactionEvent.transaction === 'GET /action-test'; + }); + + await page.goto('/action-test'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toMatchObject({ + transaction: 'GET /action-test', + }); + + const traceId = transactionEvent.contexts?.trace?.trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + }); + + test('action submission creates a transaction', async ({ page }) => { + await page.goto('/action-test'); + + const transactionEventPromise = waitForTransaction('astro-5-cf-workers', transactionEvent => { + return ( + transactionEvent.transaction?.includes('action-test') && transactionEvent.transaction !== 'GET /action-test' + ); + }); + + await page.getByText('Submit Action').click(); + + // Wait for the result to appear on the page + await page.waitForSelector('#result:not(:empty)'); + + const resultText = await page.locator('#result').textContent(); + expect(resultText).toContain('success'); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/cloudflare-runtime.test.ts new file mode 100644 index 000000000000..516b97b4988d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/cloudflare-runtime.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('Cloudflare Runtime', () => { + test('Should report cloudflare as the runtime in SSR error events', async ({ page }) => { + const errorEventPromise = waitForError('astro-5-cf-workers', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === "Cannot read properties of undefined (reading 'x')"; + }); + + await page.goto('/ssr-error').catch(() => { + // Expected to fail with net::ERR_HTTP_RESPONSE_CODE_FAILURE + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + + // The SDK info should include cloudflare in the packages + expect(errorEvent.sdk?.packages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'npm:@sentry/cloudflare', + }), + ]), + ); + }); + + test('Should report cloudflare as the runtime in API route error events', async ({ request }) => { + const errorEventPromise = waitForError('astro-5-cf-workers', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => + value.value?.includes('This is a test error from an API route'), + ); + }); + + request.get('/api/test-error').catch(() => { + // Expected to fail + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + + expect(errorEvent.sdk?.packages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'npm:@sentry/cloudflare', + }), + ]), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/errors.server.test.ts new file mode 100644 index 000000000000..df23d740e830 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tests/errors.server.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', () => { + test('captures SSR error', async ({ page }) => { + const errorEventPromise = waitForError('astro-5-cf-workers', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === "Cannot read properties of undefined (reading 'x')"; + }); + + const transactionEventPromise = waitForTransaction('astro-5-cf-workers', transactionEvent => { + return transactionEvent.transaction === 'GET /ssr-error'; + }); + + // This page returns an error status code, so we need to catch the navigation error + await page.goto('/ssr-error').catch(() => { + // Expected to fail with net::ERR_HTTP_RESPONSE_CODE_FAILURE in newer Chromium versions + }); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toMatchObject({ + transaction: 'GET /ssr-error', + }); + + const traceId = transactionEvent.contexts?.trace?.trace_id; + const spanId = transactionEvent.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(spanId).toMatch(/[a-f0-9]{16}/); + + expect(errorEvent).toMatchObject({ + contexts: { + trace: { + span_id: spanId, + trace_id: traceId, + }, + }, + environment: 'qa', + event_id: expect.stringMatching(/[a-f0-9]{32}/), + exception: { + values: [ + { + mechanism: expect.objectContaining({ + handled: false, + }), + stacktrace: expect.any(Object), + type: 'TypeError', + value: "Cannot read properties of undefined (reading 'x')", + }, + ], + }, + request: { + headers: expect.objectContaining({ + host: expect.any(String), + 'user-agent': expect.any(String), + }), + method: 'GET', + url: expect.stringContaining('/ssr-error'), + }, + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.cloudflare', + packages: expect.any(Array), + version: expect.any(String), + }, + timestamp: expect.any(Number), + transaction: 'GET /ssr-error', + }); + }); + + test('captures endpoint error', async ({ page }) => { + const errorEventPromise = waitForError('astro-5-cf-workers', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Endpoint Error'; + }); + const transactionEventApiPromise = waitForTransaction('astro-5-cf-workers', transactionEvent => { + return transactionEvent.transaction === 'GET /endpoint-error/api'; + }); + const transactionEventEndpointPromise = waitForTransaction('astro-5-cf-workers', transactionEvent => { + return transactionEvent.transaction === 'GET /endpoint-error'; + }); + + await page.goto('/endpoint-error'); + await page.getByText('Get Data').click(); + + const errorEvent = await errorEventPromise; + const transactionEventApi = await transactionEventApiPromise; + const transactionEventEndpoint = await transactionEventEndpointPromise; + + expect(transactionEventEndpoint).toMatchObject({ + transaction: 'GET /endpoint-error', + }); + + const traceId = transactionEventEndpoint.contexts?.trace?.trace_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + + expect(transactionEventApi).toMatchObject({ + transaction: 'GET /endpoint-error/api', + }); + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + mechanism: expect.objectContaining({ + handled: false, + }), + stacktrace: expect.any(Object), + type: 'Error', + value: 'Endpoint Error', + }, + ], + }, + request: { + headers: expect.objectContaining({ + accept: expect.any(String), + }), + method: 'GET', + url: expect.stringContaining('endpoint-error/api?error=1'), + }, + transaction: 'GET /endpoint-error/api', + }); + }); + + test('captures API route error', async ({ request }) => { + const errorEventPromise = waitForError('astro-5-cf-workers', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => + value.value?.includes('This is a test error from an API route'), + ); + }); + + request.get('/api/test-error').catch(() => { + // Expected to fail + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + mechanism: expect.objectContaining({ + handled: false, + }), + stacktrace: expect.any(Object), + type: 'Error', + value: 'This is a test error from an API route', + }, + ], + }, + request: { + method: 'GET', + url: expect.stringContaining('/api/test-error'), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc new file mode 100644 index 000000000000..5ef4f1ff11f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "astro-5-cf-workers", + "main": "dist/_worker.js/index.js", + "compatibility_date": "2025-12-01", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "SENTRY_DSN": "https://username@domain/123", + "SENTRY_ENVIRONMENT": "qa", + "SENTRY_TRACES_SAMPLE_RATE": "1.0", + "SENTRY_TUNNEL": "http://localhost:3031/" + }, + "assets": { + "binding": "ASSETS", + "directory": "./dist" + } +} + diff --git a/packages/astro/src/integration/cloudflare.ts b/packages/astro/src/integration/cloudflare.ts new file mode 100644 index 000000000000..c07f93ed16dc --- /dev/null +++ b/packages/astro/src/integration/cloudflare.ts @@ -0,0 +1,80 @@ +import { builtinModules } from 'module'; +import type { Plugin } from 'vite'; + +// Build a set of all Node.js built-in module names, including both +// bare names (e.g. "fs") and "node:" prefixed names (e.g. "node:fs"). +const NODE_BUILTINS = new Set(builtinModules.flatMap(m => [m, `node:${m}`])); + +/** + * A Vite plugin that suppresses the "Automatically externalized node built-in module" + * warnings that Vite emits when bundling for Cloudflare Workers. + * + * These warnings are expected because `@sentry/astro` re-exports `@sentry/node` on the + * server side, and `@sentry/node` (plus OpenTelemetry) import many Node.js built-in + * modules. Vite correctly externalizes them, but warns about it. These warnings are + * harmless since Cloudflare Workers support Node.js built-ins under the `node:` prefix. + */ +export function sentryCloudflareNodeWarningPlugin(): Plugin { + return { + name: 'sentry-astro-cloudflare-suppress-node-warnings', + enforce: 'pre', + + config() { + return { + ssr: { + // Explicitly mark all Node.js built-in modules as external. + // This prevents Vite from emitting "Automatically externalized" warnings + // for each one during the SSR/Worker build. + external: [...NODE_BUILTINS], + }, + }; + }, + }; +} + +/** + * 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 + * injected into SSR page modules via `injectScript('page-ssr', ...)`. + * + * Without this, Astro actions and API routes never call `Sentry.init()`, + * because `injectScript('page-ssr')` only adds the import to page components. + * + * Additionally, this plugin wraps the Worker's default export handler with + * `@sentry/cloudflare`'s `withSentry` to provide: + * - `setAsyncLocalStorageAsyncContextStrategy()` for proper async context + * - Per-request isolation scopes via `wrapRequestHandler` + * - Trace context propagation + */ +export function sentryCloudflareVitePlugin(): Plugin { + return { + name: 'sentry-astro-cloudflare', + 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')) { + 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*;/); + + if (!defaultExportMatch) { + return undefined; + } + + const originalExpr = defaultExportMatch[1]; + const wrappedExport = `export default withSentry(() => undefined, ${originalExpr});`; + const transformedCode = [ + "import { withSentry } from '@sentry/cloudflare';", + code.replace(defaultExportMatch[0], wrappedExport), + ].join('\n'); + + return { code: transformedCode, map: null }; + }, + }; +} diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index a96685ce8033..796d6f84a12b 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -1,7 +1,9 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'; import type { AstroConfig, AstroIntegration, AstroIntegrationLogger } from 'astro'; import * as fs from 'fs'; +import { createRequire } from 'module'; import * as path from 'path'; +import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; import type { SentryOptions } from './types'; @@ -160,20 +162,57 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } + const isCloudflare = config?.adapter?.name?.startsWith('@astrojs/cloudflare'); + + if (isCloudflare) { + try { + const _require = createRequire(`${process.cwd()}/`); + _require.resolve('@sentry/cloudflare'); + } catch { + logger.error( + 'You are using the Cloudflare adapter but `@sentry/cloudflare` is not installed. ' + + 'Please install the `@sentry/cloudflare` package in your project.', + ); + process.exit(1); + } + } + if (sdkEnabled.server) { const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); + if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); + // Always inject the server config via `injectScript('page-ssr')`. + // 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 { debug && logger.info('Using default server init.'); injectScript('page-ssr', buildServerSnippet(options || {})); } - // Prevent Sentry from being externalized for SSR. - // Cloudflare like environments have Node.js APIs are available under `node:` prefix. - // Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/ - if (config?.adapter?.name.startsWith('@astrojs/cloudflare')) { + if (isCloudflare && command !== 'dev') { + // For Cloudflare production builds, additionally use a Vite plugin to: + // 1. Import the server config at the Worker entry level (so Sentry.init() runs + // for ALL requests, not just SSR pages — covers actions and API routes) + // 2. Wrap the default export with `withSentry` from @sentry/cloudflare for + // per-request isolation, async context, and trace propagation + // + // Note: We do NOT set `ssr.noExternal` here. The `@astrojs/cloudflare` adapter + // already configures Vite to bundle all dependencies for Workers. Explicitly + // adding `@sentry/node` to `noExternal` would cause Vite to emit dozens of + // warnings about auto-externalizing Node.js built-in modules that @sentry/node + // and its transitive dependencies (OpenTelemetry, etc.) import. + debug && logger.info('Adding Cloudflare Vite plugin to wrap Worker entry with withSentry.'); + updateConfig({ + vite: { + plugins: [sentryCloudflareNodeWarningPlugin(), sentryCloudflareVitePlugin()], + }, + }); + } else if (isCloudflare) { + // Prevent Sentry from being externalized for SSR. + // Cloudflare environments have Node.js APIs available under `node:` prefix. + // Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/ updateConfig({ vite: { ssr: { @@ -187,7 +226,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } - const isSSR = config && (config.output === 'server' || config.output === 'hybrid'); + // In Astro 5+, `config.output` is no longer explicitly set — having an adapter + // implies SSR capability. We check for the adapter to handle this correctly. + const isSSR = config && (config.output === 'server' || config.output === 'hybrid' || !!config.adapter); const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false; // Guarding calling the addMiddleware function because it was only introduced in astro@3.5.0