Skip to content

Commit eade1ae

Browse files
JPeer264claude
andauthored
test(cloudflare): Resend requests until the deployed Cloudflare Worker answers (#24560)
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 <noreply@anthropic.com>
1 parent d5a1b35 commit eade1ae

8 files changed

Lines changed: 78 additions & 31 deletions

File tree

dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs renamed to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
66

77
const __dirname = dirname(fileURLToPath(import.meta.url));
88

9-
function wrangler(args, env = {}) {
9+
function wrangler(args: string[], env: Record<string, string> = {}): void {
1010
execFileSync('pnpm', ['exec', 'wrangler', ...args], {
1111
cwd: __dirname,
1212
env: { ...process.env, ...env },
@@ -18,10 +18,12 @@ function wrangler(args, env = {}) {
1818
* Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the
1919
* config wrangler deploys from, and `.wrangler/deploy/config.json` points to it.
2020
*/
21-
function nameWorkflowsAfterWorker(name) {
22-
const redirect = JSON.parse(readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'));
21+
function nameWorkflowsAfterWorker(name: string): void {
22+
const redirect: { configPath: string } = JSON.parse(
23+
readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'),
24+
);
2325
const configPath = join(__dirname, '.wrangler/deploy', redirect.configPath);
24-
const config = JSON.parse(readFileSync(configPath, 'utf8'));
26+
const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8'));
2527

2628
for (const workflow of config.workflows ?? []) {
2729
workflow.name = name;
@@ -30,7 +32,7 @@ function nameWorkflowsAfterWorker(name) {
3032
}
3133

3234
/** Deploys the worker under `name` and returns its workers.dev URL. */
33-
export function deployWorker(name, dsn) {
35+
export function deployWorker(name: string, dsn: string): string {
3436
nameWorkflowsAfterWorker(name);
3537
const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-'));
3638
const outputFile = join(outputDir, 'output.ndjson');
@@ -41,7 +43,7 @@ export function deployWorker(name, dsn) {
4143
const url = readFileSync(outputFile, 'utf8')
4244
.split('\n')
4345
.filter(Boolean)
44-
.map(line => JSON.parse(line))
46+
.map(line => JSON.parse(line) as { type?: string; targets?: string[] })
4547
.find(entry => entry.type === 'deploy')
4648
?.targets?.find(target => target.endsWith('.workers.dev'));
4749

@@ -55,20 +57,20 @@ export function deployWorker(name, dsn) {
5557
}
5658
}
5759

58-
export function deleteWorker(name) {
60+
export function deleteWorker(name: string): void {
5961
wrangler(['delete', '--name', name, '--force']);
6062
}
6163

6264
/**
6365
* CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the
6466
* cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set.
6567
*/
66-
export function keepsWorker() {
68+
export function keepsWorker(): boolean {
6769
return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER);
6870
}
6971

7072
/** A freshly created workers.dev route can take a moment to become reachable. */
71-
export async function waitForWorker(url) {
73+
export async function waitForWorker(url: string): Promise<void> {
7274
const deadline = Date.now() + 60_000;
7375

7476
while (Date.now() < deadline) {
@@ -88,3 +90,39 @@ export async function waitForWorker(url) {
8890

8991
throw new Error(`Worker at ${url} did not become reachable within 60s.`);
9092
}
93+
94+
/**
95+
* Sends a request until the Worker itself answers it, and returns the body of that answer.
96+
*
97+
* On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while
98+
* Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker
99+
* that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500
100+
* that did not come from the Worker.
101+
*/
102+
export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise<string> {
103+
const deadline = Date.now() + 60_000;
104+
let lastAnswer = 'no answer';
105+
106+
while (Date.now() < deadline) {
107+
try {
108+
const response = await fetch(url, init);
109+
const body = await response.text();
110+
// Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as
111+
// `error code: <code>` plain text to others, so the code is read from either format.
112+
const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean);
113+
114+
if (response.status === status && (status !== 500 || errorCode === '1101')) {
115+
return body;
116+
}
117+
118+
lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`;
119+
} catch (error) {
120+
lastAnswer = String(error);
121+
}
122+
123+
console.log(`The Worker did not answer ${url}: ${lastAnswer}`);
124+
await new Promise(resolve => setTimeout(resolve, 2_000));
125+
}
126+
127+
throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`);
128+
}

dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs renamed to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomBytes } from 'node:crypto';
22
import { existsSync } from 'node:fs';
3-
import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs';
3+
import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker';
44

55
const WORKER_PREFIX = 'e2e-send-to-sentry';
66

@@ -9,7 +9,7 @@ const WORKER_PREFIX = 'e2e-send-to-sentry';
99
* next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs
1010
* like `gh-readonly-queue/<base>/pr-123-<sha>`; both map to the PR's Worker.
1111
*/
12-
export function getWorkerName() {
12+
export function getWorkerName(): string {
1313
if (!process.env.GITHUB_ACTIONS) {
1414
return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`;
1515
}
@@ -24,7 +24,7 @@ export function getWorkerName() {
2424
return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, '');
2525
}
2626

27-
export default async function globalSetup() {
27+
export default async function globalSetup(): Promise<void> {
2828
if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) {
2929
throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.');
3030
}

dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs renamed to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { deleteWorker, keepsWorker } from './deployed-worker.mjs';
1+
import { deleteWorker, keepsWorker } from './deployed-worker';
22

3-
export default function globalTeardown() {
3+
export default function globalTeardown(): void {
44
const workerName = process.env.E2E_TEST_WORKER_NAME;
55

66
if (!workerName) {

‎dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"build": "vite build",
8-
"typecheck": "tsc --noEmit",
8+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
99
"test": "playwright test",
1010
"clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler",
1111
"test:build": "pnpm install && pnpm build",

‎dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { defineConfig } from '@playwright/test';
33
export default defineConfig({
44
testDir: './tests',
55
// The worker is deployed once for the whole run and deleted again afterwards.
6-
globalSetup: './global-setup.mjs',
7-
globalTeardown: './global-teardown.mjs',
6+
globalSetup: './global-setup.ts',
7+
globalTeardown: './global-teardown.ts',
88
/* Spans take ~2min to become queryable via the trace endpoint. */
99
timeout: 210_000,
1010
fullyParallel: true,

‎dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {
88
flattenTrace,
99
traceTarget,
1010
} from '@sentry-internal/test-utils/cli';
11+
import { fetchFromWorker } from '../deployed-worker';
1112

12-
// Set by global-setup.mjs once the worker for this run is deployed.
13+
// Set by global-setup.ts once the worker for this run is deployed.
1314
const workerUrl = process.env.E2E_TEST_WORKER_URL;
1415

1516
test('Sends a captured exception to Sentry', async () => {
16-
const response = await fetch(`${workerUrl}/test-error`);
17-
expect(response.status).toBe(200);
18-
const { eventId, traceId } = await response.json();
17+
const { eventId, traceId }: { eventId: string; traceId: string } = JSON.parse(
18+
await fetchFromWorker(`${workerUrl}/test-error`, 200),
19+
);
1920

2021
console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`);
2122

@@ -25,13 +26,12 @@ test('Sends a captured exception to Sentry', async () => {
2526
test('Sends an unhandled exception and its request span to Sentry', async () => {
2627
const traceId = randomBytes(16).toString('hex');
2728
const publicKey = new URL(process.env.E2E_TEST_DSN!).username;
28-
const response = await fetch(`${workerUrl}/test-unhandled-error`, {
29+
await fetchFromWorker(`${workerUrl}/test-unhandled-error`, 500, {
2930
headers: {
3031
'sentry-trace': `${traceId}-${randomBytes(8).toString('hex')}-1`,
3132
baggage: `sentry-trace_id=${traceId},sentry-public_key=${publicKey},sentry-sampled=true,sentry-sample_rate=1`,
3233
},
3334
});
34-
expect(response.status).toBe(500);
3535

3636
console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`);
3737

@@ -40,9 +40,9 @@ test('Sends an unhandled exception and its request span to Sentry', async () =>
4040
});
4141

4242
test('Sends a request span to Sentry', async () => {
43-
const response = await fetch(`${workerUrl}/test-span`);
44-
expect(response.status).toBe(200);
45-
const { spanId, traceId } = await response.json();
43+
const { spanId, traceId }: { spanId: string; traceId: string } = JSON.parse(
44+
await fetchFromWorker(`${workerUrl}/test-span`, 200),
45+
);
4646

4747
console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`);
4848

@@ -52,9 +52,9 @@ test('Sends a request span to Sentry', async () => {
5252
});
5353

5454
test('Sends the spans of Workflow steps before the Workflow goes to sleep', async () => {
55-
const response = await fetch(`${workerUrl}/test-workflow-sleep`);
56-
expect(response.status).toBe(200);
57-
const { instanceId, traceId } = await response.json();
55+
const { instanceId, traceId }: { instanceId: string; traceId: string } = JSON.parse(
56+
await fetchFromWorker(`${workerUrl}/test-workflow-sleep`, 200),
57+
);
5858

5959
console.log(`Polling for the Workflow step spans: sentry trace view ${traceTarget(traceId)}`);
6060

@@ -68,6 +68,8 @@ test('Sends the spans of Workflow steps before the Workflow goes to sleep', asyn
6868
)
6969
.toBe(3);
7070

71-
const { status } = await fetch(`${workerUrl}/test-workflow-status?id=${instanceId}`).then(res => res.json());
71+
const { status }: { status: string } = JSON.parse(
72+
await fetchFromWorker(`${workerUrl}/test-workflow-status?id=${instanceId}`, 200),
73+
);
7274
expect(['running', 'waiting']).toContain(status);
7375
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"]
7+
}

‎dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "node_modules/wrangler/config-schema.json",
3-
// Placeholder only: every test run deploys under a unique name, see global-setup.mjs.
3+
// Placeholder only: every test run deploys under a unique name, see global-setup.ts.
44
"name": "cloudflare-workers-send-to-sentry",
55
"main": "src/index.ts",
66
"compatibility_date": "2026-05-20",

0 commit comments

Comments
 (0)