From a52f5ca417dc4ed0ce5c3e63a317aca607e4809f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 9 Sep 2026 16:34:13 +0200 Subject: [PATCH 1/7] test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry Local wrangler cannot reproduce what the platform does around waitUntil, isolate reuse and Durable Object hibernation. The new optional E2E app builds with the Sentry Vite plugin, deploys one Worker per ref in CI, verifies errors and request spans in Sentry through the CLI helpers, and deletes PR Workers through a new workflow when the PR closes. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 3 + .github/workflows/cleanup-e2e-workers.yml | 36 +++++++++++ dev-packages/e2e-tests/.env.example | 6 ++ dev-packages/e2e-tests/README.md | 4 ++ .../.gitignore | 5 ++ .../deployed-worker.mjs | 61 +++++++++++++++++++ .../global-setup.mjs | 54 ++++++++++++++++ .../global-teardown.mjs | 16 +++++ .../package.json | 35 +++++++++++ .../playwright.config.ts | 16 +++++ .../src/env.d.ts | 3 + .../src/index.ts | 23 +++++++ .../src/instrument.server.ts | 9 +++ .../tests/send-to-sentry.test.ts | 48 +++++++++++++++ .../tsconfig.json | 16 +++++ .../vite.config.ts | 9 +++ .../wrangler.jsonc | 11 ++++ 17 files changed, 355 insertions(+) create mode 100644 .github/workflows/cleanup-e2e-workers.yml create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70256b2a3276..7f7013812884 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1150,6 +1150,9 @@ jobs: E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks' E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' 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 1a709a9b02b9..180fb07078a1 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 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 }, +} From 9380e2fc5c72f46ce93fe93d9be0273992194544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 17 Sep 2026 13:50:34 +0200 Subject: [PATCH 2/7] Update dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs Co-authored-by: isaacs --- .../cloudflare-workers-send-to-sentry/global-setup.mjs | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 6a0da535e110..88ac49166e82 100644 --- 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 @@ -24,7 +24,13 @@ export function getWorkerName() { } export default async function globalSetup() { + if (!existsSync(join(__dirname, '.wrangler/deploy/config.json'))) { + throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); + } const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; + if (!E2E_TEST_DSN) { + throw new Error('E2E_TEST_DSN must be set to deploy the test worker.'); + } // 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. From 4b3c184dd953543809097212d1cc779f86510f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 17 Sep 2026 13:51:54 +0200 Subject: [PATCH 3/7] Update dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json Co-authored-by: isaacs --- .../cloudflare-workers-send-to-sentry/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 57668d5bb1a9..75bbb77d0b7c 100644 --- 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 @@ -7,7 +7,7 @@ "build": "vite build", "typecheck": "tsc --noEmit", "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm typecheck && pnpm test" }, From b05b2e5b7687101118dbfa05b1aae5fc20034fea Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 17 Sep 2026 14:04:41 +0200 Subject: [PATCH 4/7] ref: Make a try/catch around deleteWorker --- .../global-teardown.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 3da0b9bfeaa5..0fee903dc5c7 100644 --- 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 @@ -12,5 +12,13 @@ export default function globalTeardown() { return; } - deleteWorker(workerName); + try { + deleteWorker(workerName); + } catch (error) { + // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. + console.error( + `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, + error, + ); + } } From 235f01e3cce4dd0986471abe9cb8a3f8ab399849 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 17 Sep 2026 14:13:38 +0200 Subject: [PATCH 5/7] ref: Remove comment --- .../tests/send-to-sentry.test.ts | 3 --- 1 file changed, 3 deletions(-) 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 index 7883f541f4ce..49813f134b10 100644 --- 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 @@ -16,9 +16,6 @@ test('Sends a captured exception to Sentry', async () => { }); 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`, { From e589a4d365f229ce95609a239dce307e9cabf64b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 17 Sep 2026 14:25:23 +0200 Subject: [PATCH 6/7] ref: Yeeting regex. Yoinking json --- .../deployed-worker.mjs | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) 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 index 3dc72865934c..da689df28552 100644 --- 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 @@ -1,29 +1,42 @@ import { execFileSync } from 'node:child_process'; -import { dirname } from 'node:path'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } 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], { +function wrangler(args, env = {}) { + execFileSync('pnpm', ['exec', 'wrangler', ...args], { cwd: __dirname, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'], + env: { ...process.env, ...env }, + stdio: ['ignore', 'inherit', '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]; + const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); + const outputFile = join(outputDir, 'output.ndjson'); - if (!url) { - throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); - } + try { + wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`], { WRANGLER_OUTPUT_FILE_PATH: outputFile }); + + const url = readFileSync(outputFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)) + .find(entry => entry.type === 'deploy') + ?.targets?.find(target => target.endsWith('.workers.dev')); - return url; + if (!url) { + throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); + } + + return url; + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } } export function deleteWorker(name) { From cb25b55817f2fee747fa7592bcd708fc0da37360 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 17 Sep 2026 16:27:50 +0200 Subject: [PATCH 7/7] fixup! test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry Co-Authored-By: Claude Opus 5 --- .../cloudflare-workers-send-to-sentry/global-setup.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 88ac49166e82..08e9311cb7ca 100644 --- 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 @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; const WORKER_PREFIX = 'e2e-send-to-sentry'; @@ -24,7 +25,7 @@ export function getWorkerName() { } export default async function globalSetup() { - if (!existsSync(join(__dirname, '.wrangler/deploy/config.json'))) { + if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); } const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env;