Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): void {
execFileSync('pnpm', ['exec', 'wrangler', ...args], {
cwd: __dirname,
env: { ...process.env, ...env },
Expand All @@ -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;
Expand All @@ -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');
Expand All @@ -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'));

Expand All @@ -55,20 +57,20 @@ export function deployWorker(name, dsn) {
}
}

export function deleteWorker(name) {
export function deleteWorker(name: string): void {
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() {
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<void> {
const deadline = Date.now() + 60_000;

while (Date.now() < deadline) {
Expand All @@ -88,3 +90,39 @@ 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 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<string> {
const deadline = Date.now() + 60_000;
let lastAnswer = 'no answer';

while (Date.now() < deadline) {
try {
Comment thread
sentry[bot] marked this conversation as resolved.
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: <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 || errorCode === '1101')) {
return body;
}

lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`;
} catch (error) {
lastAnswer = String(error);
}
Comment thread
JPeer264 marked this conversation as resolved.

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}`);
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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/<base>/pr-123-<sha>`; 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')}`;
}
Expand All @@ -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<void> {
if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) {
throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);

Expand All @@ -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)}`);

Expand All @@ -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)}`);

Expand All @@ -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)}`);

Expand All @@ -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);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"]
}
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading