Skip to content
Draft
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 @@ -1149,6 +1149,9 @@ jobs:
REACT_APP_E2E_TEST_DSN: ${{ secrets.E2E_TEST_DSN }}
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
# 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:
# 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 @@ -11,3 +11,9 @@ E2E_TEST_SENTRY_ORG_SLUG=

# A Sentry project slug
E2E_TEST_SENTRY_PROJECT=

# 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,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.`);
}
Original file line number Diff line number Diff line change
@@ -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/<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() {
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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
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",
"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,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 });
});
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()],
});
Original file line number Diff line number Diff line change
@@ -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 },
}
Loading