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 971ba3057c10..f15677f95df9 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. *