From 18e73f2bd8b953199b9cf5915b3a4927bd2a58a9 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 22 Oct 2026 12:34:44 +0200 Subject: [PATCH] test(cloudflare): Add e2e test for the Cloudflare trace id without tracing The new `cloudflare-workers-without-tracing-send-to-sentry` app deploys a real Worker with `tracesSampleRate: 0` and Cloudflare traces on. Its test checks that an error in Sentry has the trace id of the Cloudflare trace and the Cloudflare request span as its parent. The test finds the Cloudflare request span in Workers Logs by the `cf-ray` of the request, with the new `fetchWorkersLogsEvents` helper. The query endpoint needs a `CLOUDFLARE_API_TOKEN` with "Workers Observability: Edit". Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cleanup-e2e-workers.yml | 1 + dev-packages/e2e-tests/.env.example | 2 + dev-packages/e2e-tests/README.md | 3 + .../.gitignore | 5 ++ .../global-setup.ts | 3 + .../global-teardown.ts | 1 + .../package.json | 34 +++++++++++ .../playwright.config.ts | 13 +++++ .../pnpm-workspace.yaml | 4 ++ .../src/env.d.ts | 3 + .../src/index.ts | 16 ++++++ .../src/instrument.server.ts | 6 ++ .../tests/send-to-sentry.test.ts | 36 ++++++++++++ .../tsconfig.json | 16 ++++++ .../tsconfig.node.json | 7 +++ .../vite.config.ts | 7 +++ .../wrangler.jsonc | 11 ++++ dev-packages/test-utils/src/cli.ts | 1 + dev-packages/test-utils/src/cloudflare.ts | 56 +++++++++++++++++++ 19 files changed, 225 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index 917de52e997d..610efadef033 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -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 diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 3815cae556f0..09b051d0e848 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -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= diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index d71ba9b1b8a3..ac4c3faebcbf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -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. diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts new file mode 100644 index 000000000000..5a3e6215ca04 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts @@ -0,0 +1,3 @@ +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; + +export default createWorkerGlobalSetup({ workerPrefix: 'e2e-without-tracing-send-to-sentry' }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts new file mode 100644 index 000000000000..c7bcfe696e66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts @@ -0,0 +1 @@ +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json new file mode 100644 index 000000000000..9dfe01fc5709 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json @@ -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 + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..9452ae9ac890 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml new file mode 100644 index 000000000000..fd29a82085e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true + workerd: true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts new file mode 100644 index 000000000000..2f403c3d780f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts new file mode 100644 index 000000000000..51377aa24309 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + tracesSampleRate: 0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..ec0da7fc8ce3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts @@ -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); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json @@ -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"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..c49bedb6b3e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts @@ -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()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..ea985e5a54b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc @@ -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 } }, +} diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index 7e0d92dded70..212b83dba125 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -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. */ diff --git a/dev-packages/test-utils/src/cloudflare.ts b/dev-packages/test-utils/src/cloudflare.ts index f50908f03dc4..7dfd0a3445ce 100644 --- a/dev-packages/test-utils/src/cloudflare.ts +++ b/dev-packages/test-utils/src/cloudflare.ts @@ -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): Promise { + 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. *