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
1 change: 1 addition & 0 deletions .github/workflows/cleanup-e2e-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
worker-prefix:
- e2e-send-to-sentry
- e2e-flue-send-to-sentry
- e2e-without-tracing-send-to-sentry
steps:
- name: Set up Node
uses: actions/setup-node@v7
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ E2E_OPENAI_DECISIONS_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.
# Apps that read Workers Logs (e.g. cloudflare-workers-without-tracing-send-to-sentry) need an API token with
# "Workers Observability: Edit".
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
3 changes: 3 additions & 0 deletions dev-packages/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Wor
`cloudflare-flue-send-to-sentry/src/auth.ts`.
- Add the `workerPrefix` to `.github/workflows/cleanup-e2e-workers.yml`, which deletes the Worker of a PR when the PR
closes.
- To check what Cloudflare stored for a request (for example its Cloudflare trace id), read Workers Logs with
`fetchWorkersLogsEvents`. `CLOUDFLARE_API_TOKEN` then also needs "Workers Observability: Edit". See
`cloudflare-workers-without-tracing-send-to-sentry`.
- Set `"sentryTest": { "optional": true }` in `package.json`, because only the optional CI job has the Cloudflare
secrets.

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,3 @@
import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare';

export default createWorkerGlobalSetup({ workerPrefix: 'e2e-without-tracing-send-to-sentry' });
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "cloudflare-workers-without-tracing-send-to-sentry",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"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",
"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.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@types/node": "^26.1.2",
"sentry": "~0.44.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0"
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"optional": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig(undefined, {
// The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start.
webServer: undefined,
// The worker is deployed once for the whole run and deleted again afterwards.
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
// A test polls Workers Logs and then Sentry, each for up to 180s.
timeout: 400_000,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
workerd: true
minimumReleaseAge: 0
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,16 @@
import * as Sentry from '@sentry/cloudflare';

export default {
async fetch(request) {
switch (new URL(request.url).pathname) {
case '/test-error': {
const eventId = Sentry.captureException(new Error('E2E test error in a Cloudflare trace'));
// Workers Logs store the `cf-ray` of the request with the invocation, so the test finds the Cloudflare trace
// with it.
return Response.json({ eventId, rayId: request.headers.get('cf-ray') });
}
default:
return new Response('Hello World!');
}
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: Env) => ({
dsn: env.E2E_TEST_DSN,
tracesSampleRate: 0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test';
import { EVENT_POLLING_OPTIONS, fetchEvent, traceTarget } from '@sentry-internal/test-utils/cli';
import type { ApiEvent } from '@sentry-internal/test-utils/cli';
import { fetchFromWorker, fetchWorkersLogsEvents } from '@sentry-internal/test-utils/cloudflare';
import type { WorkersLogsEvent } from '@sentry-internal/test-utils/cloudflare';

// Set by global-setup.ts once the worker for this run is deployed.
const workerUrl = process.env.E2E_TEST_WORKER_URL;

test('Sends an error in the Cloudflare trace to Sentry', async () => {
const { eventId, rayId }: { eventId: string; rayId: string } = JSON.parse(
await fetchFromWorker(`${workerUrl}/test-error`, 200),
);

// The span of the request is the root span of the Cloudflare trace, and the only span with the `cf-ray` of the
// request.
let cloudflareSpans: WorkersLogsEvent[] = [];
await expect
.poll(
async () =>
(cloudflareSpans = await fetchWorkersLogsEvents({ '$metadata.rayId': rayId, '$metadata.type': 'span' })),
EVENT_POLLING_OPTIONS,
)
.toHaveLength(1);
const { traceId, spanId } = cloudflareSpans[0]!.$metadata;
expect(traceId).toEqual(expect.stringMatching(/^[\da-f]{32}$/));
expect(spanId).toEqual(expect.stringMatching(/^[\da-f]{16}$/));

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

let event: ApiEvent | undefined;
await expect.poll(() => (event = fetchEvent(eventId)), EVENT_POLLING_OPTIONS).toBeDefined();

expect(event?.contexts?.trace?.trace_id).toBe(traceId);
expect(event?.contexts?.trace?.parent_span_id).toBe(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,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

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.ts.
"name": "cloudflare-workers-without-tracing-send-to-sentry",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
// The tests read the Cloudflare trace of each request from Workers Logs.
"observability": { "enabled": true, "traces": { "enabled": true } },
}
1 change: 1 addition & 0 deletions dev-packages/test-utils/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export interface ApiEvent {
type: string;
data: { values?: { type?: string; value?: string; mechanism?: { type?: string; handled?: boolean } }[] };
}[];
contexts?: { trace?: { trace_id?: string; span_id?: string; parent_span_id?: string } };
}

/** Fetch an error event of the E2E test project. Returns `undefined` while the event is not stored yet. */
Expand Down
56 changes: 56 additions & 0 deletions dev-packages/test-utils/src/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,62 @@ export function workerGlobalTeardown(config: FullConfig): void {
}
}

/** An event of Workers Logs: an invocation, a `console` call, or a span of the Cloudflare trace. */
export interface WorkersLogsEvent {
$metadata: {
/** The `cf-ray` header of the request that the event belongs to. */
rayId?: string;
/** `span` for a span of the Cloudflare trace, `cf-worker-event` for an invocation. */
type?: string;
traceId?: string;
spanId?: string;
};
}

/**
* Returns the Workers Logs events of the last hour with the given values, for example
* `{ '$metadata.rayId': rayId }`. Returns an empty list while the events are not queryable yet.
*
* `CLOUDFLARE_API_TOKEN` needs the "Workers Observability: Edit" permission, which the query endpoint
* requires also for reads. A `wrangler login` session does not have it.
*/
export async function fetchWorkersLogsEvents(values: Record<string, string>): Promise<WorkersLogsEvent[]> {
const now = Date.now();
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/workers/observability/telemetry/query`,
{
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
queryId: 'sentry-e2e-tests',
// Runs the query without saving it to the account.
dry: true,
view: 'events',
limit: 100,
timeframe: { from: now - 60 * 60 * 1000, to: now },
parameters: {
filters: Object.entries(values).map(([key, value]) => ({ key, operation: 'eq', type: 'string', value })),
},
}),
},
);

if (response.status === 429) {
return [];
}

const body = await response.text();

if (!response.ok) {
throw new Error(
`The Workers Logs query failed with status ${response.status}: ${body}. ` +
'CLOUDFLARE_API_TOKEN needs the "Workers Observability: Edit" permission.',
);
}

return (JSON.parse(body) as { result: { events?: { events?: WorkersLogsEvent[] } } }).result.events?.events ?? [];
}

/**
* Sends a request until the Worker itself answers it, and returns the body of that answer.
*
Expand Down