diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json new file mode 100644 index 000000000000..160b8a9cdc03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -0,0 +1,38 @@ +{ + "name": "cloudflare-local-workers", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts new file mode 100644 index 000000000000..73abbd951b90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts new file mode 100644 index 000000000000..daca399a1034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + const url = new URL(request.url); + switch (url.pathname) { + case '/storage/put': { + await this.ctx.storage.put('test-key', 'test-value'); + return new Response('Stored'); + } + case '/storage/get': { + const value = await this.ctx.storage.get('test-key'); + return new Response(`Got: ${value}`); + } + default: { + return new Response('Not found'); + } + } + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname.startsWith('/pass-to-object/')) { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + url.pathname = url.pathname.replace('/pass-to-object/', ''); + const response = await stub.fetch(new Request(url, request)); + await new Promise(resolve => setTimeout(resolve, 500)); + return response; + } + + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..47fc687cdc8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-local-workers', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts new file mode 100644 index 000000000000..557b6e5affb8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +/** + * This must be the only test in here. + * + * Both the Worker and the Durable Object initialize their own AsyncLocalStorage + * context. Wrangler dev is currently single-threaded locally, so when a previous + * test (e.g. a websocket test) already sets up ALS, that context carries over + * and masks bugs in our instrumentation - causing this test to pass when it + * should fail. + */ +test('Worker and Durable Object both send transactions when worker calls DO', async ({ baseURL }) => { + const workerTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /pass-to-object/storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const doTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/storage/get`); + expect(response.status).toBe(200); + + const [workerTransaction, doTransaction] = await Promise.all([workerTransactionPromise, doTransactionPromise]); + + expect(workerTransaction.transaction).toBe('GET /pass-to-object/storage/get'); + expect(workerTransaction.contexts?.trace?.op).toBe('http.server'); + + expect(doTransaction.transaction).toBe('GET /storage/get'); + expect(doTransaction.contexts?.trace?.op).toBe('http.server'); + expect(doTransaction.spans?.some(span => span.op === 'db')).toBe(true); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json new file mode 100644 index 000000000000..80bfbd97acc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml new file mode 100644 index 000000000000..96788a17d4c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml @@ -0,0 +1,111 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-local-workers" +main = "src/index.ts" +compatibility_date = "2024-07-25" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +[[durable_objects.bindings]] +name = "MY_DURABLE_OBJECT" +class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index ebadd6393298..f11f8c491d37 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -48,6 +48,11 @@ export function makeFlushLock(context: ExecutionContext): FlushLock { * @returns A promise that resolves when flush and dispose are complete */ export async function flushAndDispose(client: Client | undefined, timeout = 2000): Promise { - await flush(timeout); + if (client) { + await client.flush(timeout); + } else { + await flush(timeout); + } + client?.dispose(); } diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts index 34714711c682..2a2b68aab02d 100644 --- a/packages/cloudflare/test/flush.test.ts +++ b/packages/cloudflare/test/flush.test.ts @@ -1,6 +1,8 @@ import { type ExecutionContext } from '@cloudflare/workers-types'; +import * as sentryCore from '@sentry/core'; +import { type Client } from '@sentry/core'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { makeFlushLock } from '../src/flush'; +import { flushAndDispose, makeFlushLock } from '../src/flush'; describe('Flush buffer test', () => { const waitUntilPromises: Promise[] = []; @@ -28,3 +30,35 @@ describe('Flush buffer test', () => { await expect(lock.ready).resolves.toBeUndefined(); }); }); + +describe('flushAndDispose', () => { + it('should flush and dispose the client when provided', async () => { + const mockClient = { + flush: vi.fn().mockResolvedValue(true), + dispose: vi.fn(), + } as unknown as Client; + + await flushAndDispose(mockClient, 3000); + + expect(mockClient.flush).toHaveBeenCalledWith(3000); + expect(mockClient.dispose).toHaveBeenCalled(); + }); + + it('should fall back to global flush when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalledWith(2000); + flushSpy.mockRestore(); + }); + + it('should not call dispose when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalled(); + flushSpy.mockRestore(); + }); +}); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index c831bd01a6bb..6c530da521c5 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -4,11 +4,16 @@ import { isInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; +const mocks = vi.hoisted(() => ({ + flush: vi.fn().mockResolvedValue(true), +})); + function createMockClient(hasTransport: boolean = true) { return { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: mocks.flush, getTransport: vi.fn().mockReturnValue(hasTransport ? { send: vi.fn() } : undefined), }; } @@ -263,8 +268,7 @@ describe('wrapMethodWithSentry', () => { await wrapped(); expect(waitUntil).toHaveBeenCalled(); - // flushAndDispose calls flush internally - expect(sentryCore.flush).toHaveBeenCalledWith(2000); + expect(mocks.flush).toHaveBeenCalledWith(2000); }); it('handles missing waitUntil gracefully', async () => { @@ -346,6 +350,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue(undefined), } as unknown as sentryCore.Client; @@ -377,6 +382,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue({ send: vi.fn() }), } as unknown as sentryCore.Client;