From 52ac2a3d9a549d8581a34deeea521a928a2a606a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 21 Sep 2026 14:55:27 +0200 Subject: [PATCH 1/4] test(e2e): Resend requests until the deployed Cloudflare Worker answers The unhandled exception test of cloudflare-workers-send-to-sentry lost its event in about 3 of 67 runs. In those runs Cloudflare answered the request with a 500 while Workers Logs had no invocation for it, so the Worker never ran and nothing was sent. Both inspected cases were the first deployment of a Worker name. The test only checked the status, so it polled Sentry for three minutes for an event that did not exist. All three tests now send their request through fetchFromWorker, which resends until the Worker itself answers. A Worker that threw answers with status 500 and the body "error code: 1101", which sets it apart from a 500 that did not come from the Worker. Other answers are logged with status, cf-ray and body. deployed-worker, global-setup and global-teardown are now TypeScript modules, and the typecheck covers them and the tests, so the tests no longer import deployed-worker as `any`. Co-Authored-By: Claude Fable 5.1 --- ...deployed-worker.mjs => deployed-worker.ts} | 53 +++++++++++++++---- .../{global-setup.mjs => global-setup.ts} | 6 +-- ...global-teardown.mjs => global-teardown.ts} | 4 +- .../playwright.config.ts | 4 +- .../tests/send-to-sentry.test.ts | 28 +++++----- .../tsconfig.json | 2 +- .../wrangler.jsonc | 2 +- 7 files changed, 68 insertions(+), 31 deletions(-) rename dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/{deployed-worker.mjs => deployed-worker.ts} (57%) rename dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/{global-setup.mjs => global-setup.ts} (94%) rename dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/{global-teardown.mjs => global-teardown.ts} (82%) 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.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts index 9dacbe052b8b..e2ecdf272577 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.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -function wrangler(args, env = {}) { +function wrangler(args: string[], env: Record = {}): void { execFileSync('pnpm', ['exec', 'wrangler', ...args], { cwd: __dirname, env: { ...process.env, ...env }, @@ -18,10 +18,12 @@ function wrangler(args, env = {}) { * Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the * config wrangler deploys from, and `.wrangler/deploy/config.json` points to it. */ -function nameWorkflowsAfterWorker(name) { - const redirect = JSON.parse(readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8')); +function nameWorkflowsAfterWorker(name: string): void { + const redirect: { configPath: string } = JSON.parse( + readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'), + ); const configPath = join(__dirname, '.wrangler/deploy', redirect.configPath); - const config = JSON.parse(readFileSync(configPath, 'utf8')); + const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8')); for (const workflow of config.workflows ?? []) { workflow.name = name; @@ -30,7 +32,7 @@ function nameWorkflowsAfterWorker(name) { } /** Deploys the worker under `name` and returns its workers.dev URL. */ -export function deployWorker(name, dsn) { +export function deployWorker(name: string, dsn: string): string { nameWorkflowsAfterWorker(name); const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); const outputFile = join(outputDir, 'output.ndjson'); @@ -41,7 +43,7 @@ export function deployWorker(name, dsn) { const url = readFileSync(outputFile, 'utf8') .split('\n') .filter(Boolean) - .map(line => JSON.parse(line)) + .map(line => JSON.parse(line) as { type?: string; targets?: string[] }) .find(entry => entry.type === 'deploy') ?.targets?.find(target => target.endsWith('.workers.dev')); @@ -55,7 +57,7 @@ export function deployWorker(name, dsn) { } } -export function deleteWorker(name) { +export function deleteWorker(name: string): void { wrangler(['delete', '--name', name, '--force']); } @@ -63,12 +65,12 @@ export function deleteWorker(name) { * 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() { +export function keepsWorker(): boolean { 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) { +export async function waitForWorker(url: string): Promise { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { @@ -88,3 +90,36 @@ export async function waitForWorker(url) { throw new Error(`Worker at ${url} did not become reachable within 60s.`); } + +/** + * Sends a request until the Worker itself answers it, and returns the body of that answer. + * + * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while + * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker + * that threw answers with status 500 and the body `error code: 1101`, which sets it apart from a + * 500 that did not come from the Worker. + */ +export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { + const deadline = Date.now() + 60_000; + let lastAnswer = 'no answer'; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, init); + const body = await response.text(); + + if (response.status === status && (status !== 500 || body === 'error code: 1101')) { + return body; + } + + lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, body: ${body.slice(0, 200)}`; + } catch (error) { + lastAnswer = String(error); + } + + console.log(`The Worker did not answer ${url}: ${lastAnswer}`); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`); +} 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.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts index 08e9311cb7ca..b33f187e1d94 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.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; +import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker'; const WORKER_PREFIX = 'e2e-send-to-sentry'; @@ -9,7 +9,7 @@ const WORKER_PREFIX = 'e2e-send-to-sentry'; * 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() { +export function getWorkerName(): string { if (!process.env.GITHUB_ACTIONS) { return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; } @@ -24,7 +24,7 @@ export function getWorkerName() { return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); } -export default async function globalSetup() { +export default async function globalSetup(): Promise { if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); } 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.ts similarity index 82% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts index 0fee903dc5c7..9f012cd51101 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.ts @@ -1,6 +1,6 @@ -import { deleteWorker, keepsWorker } from './deployed-worker.mjs'; +import { deleteWorker, keepsWorker } from './deployed-worker'; -export default function globalTeardown() { +export default function globalTeardown(): void { const workerName = process.env.E2E_TEST_WORKER_NAME; if (!workerName) { 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 index 0b79fb88052f..91e214f15493 100644 --- 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 @@ -3,8 +3,8 @@ 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', + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', /* Spans take ~2min to become queryable via the trace endpoint. */ timeout: 210_000, fullyParallel: true, 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 ab642bf55b1a..8578ecb7bee7 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 @@ -8,14 +8,15 @@ import { flattenTrace, traceTarget, } from '@sentry-internal/test-utils/cli'; +import { fetchFromWorker } from '../deployed-worker'; -// Set by global-setup.mjs once the worker for this run is deployed. +// Set by global-setup.ts 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(); + const { eventId, traceId }: { eventId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-error`, 200), + ); console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`); @@ -25,13 +26,12 @@ test('Sends a captured exception to Sentry', async () => { test('Sends an unhandled exception and its request span to Sentry', async () => { const traceId = randomBytes(16).toString('hex'); const publicKey = new URL(process.env.E2E_TEST_DSN!).username; - const response = await fetch(`${workerUrl}/test-unhandled-error`, { + await fetchFromWorker(`${workerUrl}/test-unhandled-error`, 500, { 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)}`); @@ -40,9 +40,9 @@ test('Sends an unhandled exception and its request span to Sentry', async () => }); 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(); + const { spanId, traceId }: { spanId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-span`, 200), + ); console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); @@ -52,9 +52,9 @@ test('Sends a request span to Sentry', async () => { }); test('Sends the spans of Workflow steps before the Workflow goes to sleep', async () => { - const response = await fetch(`${workerUrl}/test-workflow-sleep`); - expect(response.status).toBe(200); - const { instanceId, traceId } = await response.json(); + const { instanceId, traceId }: { instanceId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workflow-sleep`, 200), + ); console.log(`Polling for the Workflow step spans: sentry trace view ${traceTarget(traceId)}`); @@ -68,6 +68,8 @@ test('Sends the spans of Workflow steps before the Workflow goes to sleep', asyn ) .toBe(3); - const { status } = await fetch(`${workerUrl}/test-workflow-status?id=${instanceId}`).then(res => res.json()); + const { status }: { status: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workflow-status?id=${instanceId}`, 200), + ); expect(['running', 'waiting']).toContain(status); }); 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 index 94b03468f288..07ae83eb8cf6 100644 --- 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 @@ -12,5 +12,5 @@ "forceConsistentCasingInFileNames": true, "strict": true }, - "include": ["src/**/*", "vite.config.ts"] + "include": ["src/**/*", "tests/**/*", "*.ts"] } 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 index 351a68bd023f..d6b15b1dbf5e 100644 --- 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 @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - // Placeholder only: every test run deploys under a unique name, see global-setup.mjs. + // Placeholder only: every test run deploys under a unique name, see global-setup.ts. "name": "cloudflare-workers-send-to-sentry", "main": "src/index.ts", "compatibility_date": "2026-05-20", From 07280afa9025a4cc6db5ad9c7856bc0cb5cbdfb0 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 21 Sep 2026 16:16:44 +0200 Subject: [PATCH 2/4] fixup! test(e2e): Resend requests until the deployed Cloudflare Worker answers The Worker code is checked with @cloudflare/workers-types, whose globals replace the Node types of Buffer and URL, so the Node side of the test app failed to typecheck in the same project. Check it in tsconfig.node.json with Node types only. Co-Authored-By: Claude Opus 5 --- .../cloudflare-workers-send-to-sentry/package.json | 2 +- .../cloudflare-workers-send-to-sentry/tsconfig.json | 2 +- .../cloudflare-workers-send-to-sentry/tsconfig.node.json | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json 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 75bbb77d0b7c..3bebd9caebf1 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 @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", "test:build": "pnpm install && pnpm build", 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 index 07ae83eb8cf6..94b03468f288 100644 --- 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 @@ -12,5 +12,5 @@ "forceConsistentCasingInFileNames": true, "strict": true }, - "include": ["src/**/*", "tests/**/*", "*.ts"] + "include": ["src/**/*", "vite.config.ts"] } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..657ecc4ef24a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} From 6a36120f25b2811aabb86adffa37192aa0c83e36 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 21 Sep 2026 16:25:40 +0200 Subject: [PATCH 3/4] fixup! test(e2e): Resend requests until the deployed Cloudflare Worker answers Cloudflare sends its 1101 error page as HTML to Node's fetch and only sends the plain text `error code: 1101` to some other clients, so the strict body comparison never matched in the test. Read the error code from either format. Co-Authored-By: Claude Opus 5 --- .../deployed-worker.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts index e2ecdf272577..6198747cdb35 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts @@ -96,8 +96,8 @@ export async function waitForWorker(url: string): Promise { * * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker - * that threw answers with status 500 and the body `error code: 1101`, which sets it apart from a - * 500 that did not come from the Worker. + * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 + * that did not come from the Worker. */ export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { const deadline = Date.now() + 60_000; @@ -107,12 +107,15 @@ export async function fetchFromWorker(url: string, status: number, init?: Reques try { const response = await fetch(url, init); const body = await response.text(); + // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as + // `error code: ` plain text to others, so the code is read from either format. + const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)$/.exec(body)?.slice(1).find(Boolean); - if (response.status === status && (status !== 500 || body === 'error code: 1101')) { + if (response.status === status && (status !== 500 || errorCode === '1101')) { return body; } - lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, body: ${body.slice(0, 200)}`; + lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`; } catch (error) { lastAnswer = String(error); } From 6ea1f58e9a278f07295732ea9597b48dedf6def9 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 22 Sep 2026 11:37:09 +0200 Subject: [PATCH 4/4] fixup! test(e2e): Resend requests until the deployed Cloudflare Worker answers Cloudflare ends the plain text error body with a newline, and `$` without the `m` flag only matches at the very end of the string, so a plain text 1101 answer never matched. Co-Authored-By: Claude Opus 5 --- .../cloudflare-workers-send-to-sentry/deployed-worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts index 6198747cdb35..514927322439 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts @@ -109,7 +109,7 @@ export async function fetchFromWorker(url: string, status: number, init?: Reques const body = await response.text(); // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as // `error code: ` plain text to others, so the code is read from either format. - const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)$/.exec(body)?.slice(1).find(Boolean); + const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean); if (response.status === status && (status !== 500 || errorCode === '1101')) { return body;