diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23fb501d72b3..46c493ead398 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1151,6 +1151,9 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' # Used by the `node-eve` test app to make real model calls through OpenRouter E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }} + # Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml new file mode 100644 index 000000000000..02fb540f71a3 --- /dev/null +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -0,0 +1,36 @@ +name: 'Automation: Cleanup E2E workers' +on: + pull_request: + types: + - closed + +jobs: + cleanup: + # The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + strategy: + matrix: + # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs + worker-prefix: + - e2e-send-to-sentry + steps: + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Delete worker + run: | + WORKER="${{ matrix.worker-prefix }}-pr-${{ github.event.pull_request.number }}" + + if ! output=$(npx --yes wrangler@4 delete --name "$WORKER" --force 2>&1); then + echo "$output" + # 10007 means the worker does not exist, i.e. the PR never ran the optional E2E job. + echo "$output" | grep -q 'code: 10007' || exit 1 + fi diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 13eff7352024..3665548ef14b 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -15,3 +15,9 @@ E2E_TEST_SENTRY_PROJECT= # An OpenRouter API key, used by the `node-eve` test app to make real model calls. # Only needed to run that test app locally. E2E_OPENROUTER_API_KEY= + +# Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry). +# The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a +# warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead. +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4145890ed605..4a398a2b9002 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -18,6 +18,10 @@ current state. - Copy `.env.example` to `.env` - OPTIONAL: Fill in auth information in `.env` for an example Sentry project - you only need this to run E2E tests that send data to Sentry. +- OPTIONAL: Fill in the Cloudflare credentials in `.env` - you only need this to run E2E tests that deploy a real + Cloudflare Worker (e.g. `cloudflare-workers-send-to-sentry`). A local run deploys a throwaway worker and deletes it + again afterwards; set `E2E_KEEP_WORKER=1` to keep it for debugging. CI keeps one worker per branch or PR instead, and + PR workers are deleted by the `cleanup-e2e-workers` workflow when the PR closes. - Run `yarn build:tarball` in the root of the repository (needs to be rerun after every update in /packages for the changes to have effect on the tests). diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs new file mode 100644 index 000000000000..3dc72865934c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function wrangler(args) { + const output = execFileSync('pnpm', ['exec', 'wrangler', ...args], { + cwd: __dirname, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + console.log(output); + return output; +} + +/** Deploys the worker under `name` and returns its workers.dev URL. */ +export function deployWorker(name, dsn) { + const output = wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`]); + const url = output.match(/https:\/\/\S+\.workers\.dev/)?.[0]; + + if (!url) { + throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); + } + + return url; +} + +export function deleteWorker(name) { + wrangler(['delete', '--name', name, '--force']); +} + +/** + * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the + * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. + */ +export function keepsWorker() { + return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); +} + +/** A freshly created workers.dev route can take a moment to become reachable. */ +export async function waitForWorker(url) { + const deadline = Date.now() + 60_000; + + while (Date.now() < deadline) { + try { + // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. + const response = await fetch(url, { method: 'HEAD' }); + + if (response.ok) { + return; + } + } catch { + // DNS for the new subdomain may not have propagated yet. + } + + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`Worker at ${url} did not become reachable within 60s.`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs new file mode 100644 index 000000000000..6a0da535e110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs @@ -0,0 +1,54 @@ +import { randomBytes } from 'node:crypto'; +import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; + +const WORKER_PREFIX = 'e2e-send-to-sentry'; + +/** + * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the + * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs + * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. + */ +export function getWorkerName() { + if (!process.env.GITHUB_ACTIONS) { + return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; + } + + const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; + const prNumber = + GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; + const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; + // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. + const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); +} + +export default async function globalSetup() { + const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; + + // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), + // but it cannot pick an account on its own outside of a terminal. + if (!CLOUDFLARE_ACCOUNT_ID) { + throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); + } + + const workerName = getWorkerName(); + const workerUrl = deployWorker(workerName, E2E_TEST_DSN); + process.env.E2E_TEST_WORKER_NAME = workerName; + + try { + await waitForWorker(workerUrl); + } catch (error) { + if (!keepsWorker()) { + try { + deleteWorker(workerName); + } catch (deleteError) { + // The unreachable worker is the failure to report, not the cleanup. + console.error(`Failed to delete worker ${workerName}:`, deleteError); + } + } + throw error; + } + + process.env.E2E_TEST_WORKER_URL = workerUrl; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs new file mode 100644 index 000000000000..3da0b9bfeaa5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs @@ -0,0 +1,16 @@ +import { deleteWorker, keepsWorker } from './deployed-worker.mjs'; + +export default function globalTeardown() { + const workerName = process.env.E2E_TEST_WORKER_NAME; + + if (!workerName) { + return; + } + + if (keepsWorker()) { + console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); + return; + } + + deleteWorker(workerName); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json new file mode 100644 index 000000000000..57668d5bb1a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -0,0 +1,35 @@ +{ + "name": "cloudflare-workers-send-to-sentry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^26.1.2", + "sentry": "~0.44.1", + "typescript": "~6.0.3", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..0b79fb88052f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + /* Spans take ~2min to become queryable via the trace endpoint. */ + timeout: 210_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + // Every test spends most of its time polling Sentry, so run them all at once. + workers: '100%', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts new file mode 100644 index 000000000000..c48a38137713 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/cloudflare'; + +export default { + async fetch(request) { + const url = new URL(request.url); + // The handler runs inside the request span the Vite plugin's `withSentry` wrapper starts, so + // this is the `http.server` span. + const spanContext = Sentry.getActiveSpan()?.spanContext(); + + switch (url.pathname) { + case '/test-error': { + const eventId = Sentry.captureException(new Error('E2E test error')); + return Response.json({ eventId, traceId: spanContext?.traceId }); + } + case '/test-unhandled-error': + throw new Error('E2E test unhandled error'); + case '/test-span': + return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); + default: + return new Response('Hello World!'); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts new file mode 100644 index 000000000000..cb0d3bfb44aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts @@ -0,0 +1,9 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in +// wrangler's `main`, and hands its default export to `withSentry`. +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..7883f541f4ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,48 @@ +import { randomBytes } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace, traceTarget } from '@sentry-internal/test-utils/cli'; + +// Set by global-setup.mjs once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL; + +test('Sends a captured exception to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-error`); + expect(response.status).toBe(200); + const { eventId, traceId } = await response.json(); + + console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends an unhandled exception and its request span to Sentry', async () => { + // The worker cannot report ids for a request it fails, so the test picks the trace id and the + // SDK continues it from the incoming headers. Relay drops streamed spans of a trace without a + // dynamic sampling context, so `baggage` has to come along with `sentry-trace`. + const traceId = randomBytes(16).toString('hex'); + const publicKey = new URL(process.env.E2E_TEST_DSN!).username; + const response = await fetch(`${workerUrl}/test-unhandled-error`, { + headers: { + 'sentry-trace': `${traceId}-${randomBytes(8).toString('hex')}-1`, + baggage: `sentry-trace_id=${traceId},sentry-public_key=${publicKey},sentry-sampled=true,sentry-sample_rate=1`, + }, + }); + expect(response.status).toBe(500); + + console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect.poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends a request span to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-span`); + expect(response.status).toBe(200); + const { spanId, traceId } = await response.json(); + + console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); + + await expect + .poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS) + .toMatchObject({ event_id: spanId }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..75a667ec89d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts @@ -0,0 +1,9 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// The Sentry plugin wraps the default export of `src/index.ts` with `withSentry` at build time and +// takes the options from `src/instrument.server.ts`, so the entry itself stays uninstrumented. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..cf5ad9bee22b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.mjs. + "name": "cloudflare-workers-send-to-sentry", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. + "observability": { "enabled": true }, +}