Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/cleanup-e2e-workers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: 'Automation: Cleanup E2E workers'
on:
pull_request:
types:
- closed

jobs:
cleanup:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good, but we never clean up workers that get created from a local job. Maybe we could have a cron or something that cleans up any with -local in the name and are older than a few weeks, or something? Could be done as a followup, but worth an issue to make sure we don't forget it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm you're right. I thought about having also a nice experience locally, but forgot the teardown experience 🤔
Cron would be great, but I don't want to delete irrelevant - I'll create a follow up for this as this sounds like a great improvement, but wouldn't be harmful for now - as workers are "for free" if they don't run.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow up: #24459

@JPeer264 JPeer264 Sep 17, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually give me a second. It actually does delete it already, because the global-teardown.mjs is actually deleting the worker already locally immediately. We have a keepsWorker() protection that keeps the worker then one of the variables is set, but locally, by default, we don't have any of these so it will be deleted right away

(it would still be good to have this cron, just in case something is off)

# 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
6 changes: 6 additions & 0 deletions dev-packages/e2e-tests/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions dev-packages/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist
.wrangler
node_modules
test-results
pnpm-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { execFileSync } from 'node:child_process';
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, env = {}) {
execFileSync('pnpm', ['exec', 'wrangler', ...args], {
cwd: __dirname,
env: { ...process.env, ...env },
stdio: ['ignore', 'inherit', 'inherit'],
});
}

/** Deploys the worker under `name` and returns its workers.dev URL. */
export function deployWorker(name, dsn) {
const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-'));
const outputFile = join(outputDir, 'output.ndjson');

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'));

Comment thread
sentry[bot] marked this conversation as resolved.
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 });
}
}
Comment thread
JPeer264 marked this conversation as resolved.

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.`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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';

/**
* 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/<base>/pr-123-<sha>`; 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() {
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;
Comment thread
JPeer264 marked this conversation as resolved.
if (!E2E_TEST_DSN) {
throw new Error('E2E_TEST_DSN must be set to deploy the test worker.');
Comment thread
sentry[bot] marked this conversation as resolved.
}

// 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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;
}

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,
);
Comment thread
JPeer264 marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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 dist .wrangler",
"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
}
}
Original file line number Diff line number Diff line change
@@ -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',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Env {
E2E_TEST_DSN: string;
}
Original file line number Diff line number Diff line change
@@ -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<Env>;
Original file line number Diff line number Diff line change
@@ -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,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 () => {
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 });
});
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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()],
});
Loading
Loading