diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index b195ec7e2716..91a49e0788f4 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -4,28 +4,35 @@ "private": true, "scripts": { "deploy": "wrangler deploy", - "dev": "wrangler dev --var E2E_TEST_DSN=$E2E_TEST_DSN", - "build": "wrangler deploy --dry-run --var E2E_TEST_DSN=$E2E_TEST_DSN", - "test": "vitest", + "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 typecheck" + "test:assert": "pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { "@sentry/cloudflare": "latest || *" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.4.5", + "@playwright/test": "~1.50.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": "1.6.1", - "wrangler": "4.22.0" + "vitest": "~3.2.0", + "wrangler": "^4.23.0", + "ws": "^8.18.3" }, "volta": { "extends": "../../package.json" }, - "sentryTest": { - "optional": true + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts new file mode 100644 index 000000000000..c69c955fafd8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts @@ -0,0 +1,21 @@ +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%', + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index a3366168fa08..f329cea238e8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -11,15 +11,100 @@ * Learn more at https://developers.cloudflare.com/workers/ */ import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from "cloudflare:workers"; + +class MyDurableObjectBase extends DurableObject { + private throwOnExit = new WeakMap(); + async throwException(): Promise { + throw new Error('Should be recorded in Sentry.'); + } + + async fetch(request: Request) { + const { pathname } = new URL(request.url); + switch (pathname) { + case '/throwException': { + await this.throwException(); + break; + } + case '/ws': + const webSocketPair = new WebSocketPair(); + const [client, server] = Object.values(webSocketPair); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + return new Response('DO is fine'); + } + + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise { + if (message === 'throwException') { + throw new Error('Should be recorded in Sentry: webSocketMessage'); + } else if (message === 'throwOnExit') { + this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose')); + } + } + + webSocketClose(ws: WebSocket): void | Promise { + if (this.throwOnExit.has(ws)) { + const error = this.throwOnExit.get(ws)!; + this.throwOnExit.delete(ws); + throw error; + } + } +} + +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, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + }), + MyDurableObjectBase, +); export default Sentry.withSentry( (env: Env) => ({ dsn: env.E2E_TEST_DSN, - // Set tracesSampleRate to 1.0 to capture 100% of spans for tracing. + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, }), { - async fetch(request, env, ctx) { + async fetch(request, env) { + const url = new URL(request.url); + switch (url.pathname) { + case '/rpc/throwException': + { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + try { + await stub.throwException(); + } catch (e) { + //We will catch this to be sure not to log inside withSentry + return new Response(null, { status: 500 }); + } + } + break; + case '/throwException': + throw new Error('To be recorded in Sentry.'); + default: + 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/', ''); + return stub.fetch(new Request(url, request)); + } + } return new Response('Hello World!'); }, } satisfies ExportedHandler, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..738ec64293b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import {startEventProxyServer} from '@sentry-internal/test-utils' + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-workers', +}) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/index.spec.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/index.spec.ts deleted file mode 100644 index 21c9d1b7999a..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/index.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import worker from '../src/index'; -// test/index.spec.ts -import { SELF, createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; - -// For now, you'll need to do something like this to get a correctly-typed -// `Request` to pass to `worker.fetch()`. -const IncomingRequest = Request; - -describe('Hello World worker', () => { - it('responds with Hello World! (unit style)', async () => { - const request = new IncomingRequest('http://example.com'); - // Create an empty context to pass to `worker.fetch()`. - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions - await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); - }); - - it('responds with Hello World! (integration style)', async () => { - const response = await SELF.fetch('https://example.com'); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/tsconfig.json deleted file mode 100644 index bc019a7e2bfb..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/test/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"] - }, - "include": ["./**/*.ts", "../src/env.d.ts"], - "exclude": [] -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts new file mode 100644 index 000000000000..ac8f2e38952e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import {WebSocket} from 'ws' + +test('Index page', async ({ baseURL }) => { + const result = await fetch(baseURL!); + expect(result.status).toBe(200); + await expect(result.text()).resolves.toBe('Hello World!'); +}) + +test('worker\'s withSentry', async ({baseURL}) => { + const eventWaiter = waitForError('cloudflare-workers', (event) => { + return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare'; + }); + const response = await fetch(`${baseURL}/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.'); +}) + +test('RPC method which throws an exception to be logged to sentry', async ({baseURL}) => { + const eventWaiter = waitForError('cloudflare-workers', (event) => { + return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject'; + }); + const response = await fetch(`${baseURL}/rpc/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); +test('Request processed by DurableObject\'s fetch is recorded', async ({baseURL}) => { + const eventWaiter = waitForError('cloudflare-workers', (event) => { + return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject'; + }); + const response = await fetch(`${baseURL}/pass-to-object/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); +test('Websocket.webSocketMessage', async ({baseURL}) => { + const eventWaiter = waitForError('cloudflare-workers', (event) => { + return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject'; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwException') + }); + const event = await eventWaiter; + socket.close(); + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage'); +}) + +test('Websocket.webSocketClose', async ({baseURL}) => { + const eventWaiter = waitForError('cloudflare-workers', (event) => { + return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject'; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwOnExit') + socket.close() + }); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose'); +}) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json new file mode 100644 index 000000000000..978ecd87b7ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts", "../worker-configuration.d.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json index 79207ab7ae9a..ca1f83e3bc15 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json @@ -2,103 +2,43 @@ "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ - /* Projects */ - // "incremental": true, /* Enable incremental compilation */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, - "jsx": "react-jsx" /* Specify what JSX code is generated. */, - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ - // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - - /* Modules */ - "module": "es2022" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + /* 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/2023-07-01" - ] /* Specify type package names to be included without being referenced in a source file. */, - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - "resolveJsonModule": true /* Enable importing .json files */, - // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, - "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - "noEmit": true /* Disable emitting files from a compilation. */, - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, - "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, - // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ - // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "./worker-configuration.d.ts" + ] }, "exclude": ["test"], "include": ["worker-configuration.d.ts", "src/**/*.ts"] diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts index 0c9e04919e42..08a92a61d05d 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts @@ -3,4 +3,5 @@ interface Env { E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-workers/wrangler.toml index 2fc762f4025c..70b0ae034580 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/wrangler.toml +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/wrangler.toml @@ -53,15 +53,15 @@ compatibility_flags = ["nodejs_compat"] # 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_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_classes = ["MyDurableObject"] +[[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 diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0e919977025d..c9de8763750c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -25,16 +25,21 @@ type MethodWrapperOptions = { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -function wrapMethodWithSentry any>( +type OriginalMethod = (...args: any[]) => any; + +function wrapMethodWithSentry( wrapperOptions: MethodWrapperOptions, handler: T, callback?: (...args: Parameters) => void, + noMark?: true, ): T { if (isInstrumented(handler)) { return handler; } - markAsInstrumented(handler); + if (!noMark) { + markAsInstrumented(handler); + } return new Proxy(handler, { apply(target, thisArg, args: Parameters) { @@ -221,8 +226,46 @@ export function instrumentDurableObjectWithSentry< ); } } + const instrumentedPrototype = instrumentPrototype(target, options, context); + Object.setPrototypeOf(obj, instrumentedPrototype); return obj; }, }); } + +function instrumentPrototype( + target: T, + options: CloudflareOptions, + context: MethodWrapperOptions['context'], +): T { + return new Proxy(target.prototype, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop === 'constructor' || typeof value !== 'function') { + return value; + } + const wrapped = wrapMethodWithSentry( + { options, context, spanName: prop.toString(), spanOp: 'rpc' }, + value, + undefined, + true, + ); + const instrumented = new Proxy(wrapped, { + get(target, p, receiver) { + if ('__SENTRY_INSTRUMENTED__' === p) { + return true; + } + return Reflect.get(target, p, receiver); + }, + }); + Object.defineProperty(receiver, prop, { + value: instrumented, + enumerable: true, + writable: true, + configurable: true, + }); + return instrumented; + }, + }); +} diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 40d33741658e..b627c4051b41 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -1,30 +1,75 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; -import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { instrumentDurableObjectWithSentry } from '../src/durableobject'; +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { instrumentDurableObjectWithSentry } from '../src'; import { isInstrumented } from '../src/instrument'; -describe('durable object', () => { - it('instrumentDurableObjectWithSentry generic functionality', () => { +describe('instrumentDurableObjectWithSentry', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('Generic functionality', () => { const options = vi.fn(); const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); expect(instrumented).toBeTypeOf('function'); expect(() => Reflect.construct(instrumented, [])).not.toThrow(); expect(options).toHaveBeenCalledOnce(); }); - it('all available durable object methods are instrumented', () => { - const testClass = vi.fn(() => ({ - customMethod: vi.fn(), - fetch: vi.fn(), - alarm: vi.fn(), - webSocketMessage: vi.fn(), - webSocketClose: vi.fn(), - webSocketError: vi.fn(), - })); + it('Instruments prototype methods and defines implementation in the object', () => { + const testClass = class { + method() {} + }; + const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + expect(obj.method).toBe(obj.method); + }); + it('Instruments prototype methods without "sticking" to the options', () => { + const initCore = vi.spyOn(SentryCore, 'initAndBind'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const options = vi + .fn() + .mockReturnValueOnce({ + orgId: 1, + }) + .mockReturnValueOnce({ + orgId: 2, + }); + const testClass = class { + method() {} + }; + (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); + (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); + expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); + }); + it('All available durable object methods are instrumented', () => { + const testClass = class { + propertyFunction = vi.fn(); + + rpcMethod() {} + + fetch() {} + + alarm() {} + + webSocketMessage() {} + + webSocketClose() {} + + webSocketError() {} + }; const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); - const dObject: any = Reflect.construct(instrumented, []); - for (const method of Object.getOwnPropertyNames(dObject)) { - expect(isInstrumented(dObject[method]), `Method ${method} is instrumented`).toBeTruthy(); + const obj = Reflect.construct(instrumented, []); + expect(Object.getPrototypeOf(obj), 'Prototype is instrumented').not.toBe(testClass.prototype); + for (const method_name of [ + 'propertyFunction', + 'fetch', + 'alarm', + 'webSocketMessage', + 'webSocketClose', + 'webSocketError', + 'rpcMethod', + ]) { + expect(isInstrumented((obj as any)[method_name]), `Method ${method_name} is instrumented`).toBeTruthy(); } }); it('flush performs after all waitUntil promises are finished', async () => {