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 },
+}