From 196395fa9ceccd80dee52e1e191491f9b0289f48 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 18 Feb 2026 08:20:59 -0100 Subject: [PATCH] ref(cloudflare): Move internal files and functions around --- .../astro-5-cf-workers/wrangler.jsonc | 7 +- packages/cloudflare/src/durableobject.ts | 155 +---------------- packages/cloudflare/src/handler.ts | 12 +- ...ecutionContext.ts => instrumentContext.ts} | 34 ++-- packages/cloudflare/src/workflows.ts | 4 +- .../cloudflare/src/wrapMethodWithSentry.ts | 159 ++++++++++++++++++ ...text.test.ts => instrumentContext.test.ts} | 22 +-- 7 files changed, 206 insertions(+), 187 deletions(-) rename packages/cloudflare/src/utils/{copyExecutionContext.ts => instrumentContext.ts} (70%) create mode 100644 packages/cloudflare/src/wrapMethodWithSentry.ts rename packages/cloudflare/test/{copy-execution-context.test.ts => instrumentContext.test.ts} (67%) diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc index 5ef4f1ff11f6..0b7b36047973 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc @@ -8,11 +8,10 @@ "SENTRY_DSN": "https://username@domain/123", "SENTRY_ENVIRONMENT": "qa", "SENTRY_TRACES_SAMPLE_RATE": "1.0", - "SENTRY_TUNNEL": "http://localhost:3031/" + "SENTRY_TUNNEL": "http://localhost:3031/", }, "assets": { "binding": "ASSETS", - "directory": "./dist" - } + "directory": "./dist", + }, } - diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index a0c042c6a755..fc07cb46ca00 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,159 +1,14 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { - captureException, - flush, - getClient, - isThenable, - type Scope, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startSpan, - withIsolationScope, - withScope, -} from '@sentry/core'; +import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; -import { init } from './sdk'; -import { copyExecutionContext } from './utils/copyExecutionContext'; - -type MethodWrapperOptions = { - spanName?: string; - spanOp?: string; - options: CloudflareOptions; - context: ExecutionContext | DurableObjectState; -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type UncheckedMethod = (...args: any[]) => any; -type OriginalMethod = UncheckedMethod; - -function wrapMethodWithSentry( - wrapperOptions: MethodWrapperOptions, - handler: T, - callback?: (...args: Parameters) => void, - noMark?: true, -): T { - if (isInstrumented(handler)) { - return handler; - } - - if (!noMark) { - markAsInstrumented(handler); - } - - return new Proxy(handler, { - apply(target, thisArg, args: Parameters) { - const currentClient = getClient(); - // if a client is already set, use withScope, otherwise use withIsolationScope - const sentryWithScope = currentClient ? withScope : withIsolationScope; - - const wrappedFunction = (scope: Scope): unknown => { - // In certain situations, the passed context can become undefined. - // For example, for Astro while prerendering pages at build time. - // see: https://github.com/getsentry/sentry-javascript/issues/13217 - const context = wrapperOptions.context as ExecutionContext | undefined; - - const waitUntil = context?.waitUntil?.bind?.(context); - - const currentClient = scope.getClient(); - if (!currentClient) { - const client = init({ ...wrapperOptions.options, ctx: context }); - scope.setClient(client); - } - - if (!wrapperOptions.spanName) { - try { - if (callback) { - callback(...args); - } - const result = Reflect.apply(target, thisArg, args); - - if (isThenable(result)) { - return result.then( - (res: unknown) => { - waitUntil?.(flush(2000)); - return res; - }, - (e: unknown) => { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flush(2000)); - throw e; - }, - ); - } else { - waitUntil?.(flush(2000)); - return result; - } - } catch (e) { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flush(2000)); - throw e; - } - } - - const attributes = wrapperOptions.spanOp - ? { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object', - } - : {}; - - return startSpan({ name: wrapperOptions.spanName, attributes }, () => { - try { - const result = Reflect.apply(target, thisArg, args); - - if (isThenable(result)) { - return result.then( - (res: unknown) => { - waitUntil?.(flush(2000)); - return res; - }, - (e: unknown) => { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flush(2000)); - throw e; - }, - ); - } else { - waitUntil?.(flush(2000)); - return result; - } - } catch (e) { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flush(2000)); - throw e; - } - }); - }; - - return sentryWithScope(wrappedFunction); - }, - }); -} +import { instrumentContext } from './utils/instrumentContext'; +import type { UncheckedMethod } from './wrapMethodWithSentry'; +import { wrapMethodWithSentry } from './wrapMethodWithSentry'; /** * Instruments a Durable Object class to capture errors and performance data. @@ -196,7 +51,7 @@ export function instrumentDurableObjectWithSentry< return new Proxy(DurableObjectClass, { construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 9d2e2728af82..0c9196740448 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -15,7 +15,7 @@ import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; -import { copyExecutionContext } from './utils/copyExecutionContext'; +import { instrumentContext } from './utils/instrumentContext'; /** * Wrapper for Cloudflare handlers. @@ -46,7 +46,7 @@ export function withSentry< handler.fetch = new Proxy(handler.fetch, { apply(target, thisArg, args: Parameters>) { const [request, env, ctx] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); @@ -82,7 +82,7 @@ export function withSentry< handler.scheduled = new Proxy(handler.scheduled, { apply(target, thisArg, args: Parameters>) { const [event, env, ctx] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[2] = context; return withIsolationScope(isolationScope => { @@ -128,7 +128,7 @@ export function withSentry< handler.email = new Proxy(handler.email, { apply(target, thisArg, args: Parameters>) { const [emailMessage, env, ctx] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[2] = context; return withIsolationScope(isolationScope => { @@ -172,7 +172,7 @@ export function withSentry< handler.queue = new Proxy(handler.queue, { apply(target, thisArg, args: Parameters>) { const [batch, env, ctx] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[2] = context; return withIsolationScope(isolationScope => { @@ -224,7 +224,7 @@ export function withSentry< handler.tail = new Proxy(handler.tail, { apply(target, thisArg, args: Parameters>) { const [, env, ctx] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[2] = context; return withIsolationScope(async isolationScope => { diff --git a/packages/cloudflare/src/utils/copyExecutionContext.ts b/packages/cloudflare/src/utils/instrumentContext.ts similarity index 70% rename from packages/cloudflare/src/utils/copyExecutionContext.ts rename to packages/cloudflare/src/utils/instrumentContext.ts index 85a007f16e18..5f5d0577f64e 100644 --- a/packages/cloudflare/src/utils/copyExecutionContext.ts +++ b/packages/cloudflare/src/utils/instrumentContext.ts @@ -4,12 +4,15 @@ type ContextType = ExecutionContext | DurableObjectState; type OverridesStore = Map unknown>; /** - * Creates a new copy of the given execution context, optionally overriding methods. + * Instruments an execution context or DurableObjectState with Sentry tracing. * - * @param {ContextType|void} ctx - The execution context to be copied. Can be of type `ContextType` or `void`. - * @return {ContextType|void} A new execution context with the same properties and overridden methods if applicable. + * Creates a copy of the context that: + * - Allows overriding of methods (e.g., waitUntil) + * + * @param ctx - The execution context or DurableObjectState to instrument + * @returns An instrumented copy of the context */ -export function copyExecutionContext(ctx: T): T { +export function instrumentContext(ctx: T): T { if (!ctx) return ctx; const overrides: OverridesStore = new Map(); @@ -17,16 +20,19 @@ export function copyExecutionContext(ctx: T): T { const prototypeMethodNames = Object.getOwnPropertyNames(contextPrototype) as unknown as (keyof T)[]; const ownPropertyNames = Object.getOwnPropertyNames(ctx) as unknown as (keyof T)[]; const instrumented = new Set(['constructor']); - const descriptors = [...ownPropertyNames, ...prototypeMethodNames].reduce((prevDescriptors, methodName) => { - if (instrumented.has(methodName)) return prevDescriptors; - if (typeof ctx[methodName] !== 'function') return prevDescriptors; - instrumented.add(methodName); - const overridableDescriptor = makeOverridableDescriptor(overrides, ctx, methodName); - return { - ...prevDescriptors, - [methodName]: overridableDescriptor, - }; - }, {}); + const descriptors: PropertyDescriptorMap = [...ownPropertyNames, ...prototypeMethodNames].reduce( + (prevDescriptors, methodName) => { + if (instrumented.has(methodName)) return prevDescriptors; + if (typeof ctx[methodName] !== 'function') return prevDescriptors; + instrumented.add(methodName); + const overridableDescriptor = makeOverridableDescriptor(overrides, ctx, methodName); + return { + ...prevDescriptors, + [methodName]: overridableDescriptor, + }; + }, + {} as PropertyDescriptorMap, + ); return Object.create(ctx, descriptors); } diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 17ec17e9cd85..30680c0f2131 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -22,7 +22,7 @@ import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; -import { copyExecutionContext } from './utils/copyExecutionContext'; +import { instrumentContext } from './utils/instrumentContext'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -158,7 +158,7 @@ export function instrumentWorkflowWithSentry< return new Proxy(WorkFlowClass, { construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { const [ctx, env] = args; - const context = copyExecutionContext(ctx); + const context = instrumentContext(ctx); args[0] = context; const options = optionsCallback(env); diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts new file mode 100644 index 000000000000..e3a4b1ce2b0a --- /dev/null +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -0,0 +1,159 @@ +import { + captureException, + flush, + getClient, + isThenable, + type Scope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + withIsolationScope, + withScope, +} from '@sentry/core'; +import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; +import { init } from './sdk'; + +type MethodWrapperOptions = { + spanName?: string; + spanOp?: string; + options: CloudflareOptions; + context: ExecutionContext | DurableObjectState; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type UncheckedMethod = (...args: any[]) => any; +type OriginalMethod = UncheckedMethod; + +/** + * Wraps a method with Sentry tracing. + * + * @param wrapperOptions - The options for the wrapper. + * @param handler - The method to wrap. + * @param callback - The callback to call. + * @param noMark - Whether to mark the method as instrumented. + * @returns The wrapped method. + */ +export function wrapMethodWithSentry( + wrapperOptions: MethodWrapperOptions, + handler: T, + callback?: (...args: Parameters) => void, + noMark?: true, +): T { + if (isInstrumented(handler)) { + return handler; + } + + if (!noMark) { + markAsInstrumented(handler); + } + + return new Proxy(handler, { + apply(target, thisArg, args: Parameters) { + const currentClient = getClient(); + // if a client is already set, use withScope, otherwise use withIsolationScope + const sentryWithScope = currentClient ? withScope : withIsolationScope; + + const wrappedFunction = (scope: Scope): unknown => { + // In certain situations, the passed context can become undefined. + // For example, for Astro while prerendering pages at build time. + // see: https://github.com/getsentry/sentry-javascript/issues/13217 + const context = wrapperOptions.context as ExecutionContext | undefined; + + const waitUntil = context?.waitUntil?.bind?.(context); + + const currentClient = scope.getClient(); + if (!currentClient) { + const client = init({ ...wrapperOptions.options, ctx: context }); + scope.setClient(client); + } + + if (!wrapperOptions.spanName) { + try { + if (callback) { + callback(...args); + } + const result = Reflect.apply(target, thisArg, args); + + if (isThenable(result)) { + return result.then( + (res: unknown) => { + waitUntil?.(flush(2000)); + return res; + }, + (e: unknown) => { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + }, + ); + } else { + waitUntil?.(flush(2000)); + return result; + } + } catch (e) { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + } + } + + const attributes = wrapperOptions.spanOp + ? { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object', + } + : {}; + + return startSpan({ name: wrapperOptions.spanName, attributes }, () => { + try { + const result = Reflect.apply(target, thisArg, args); + + if (isThenable(result)) { + return result.then( + (res: unknown) => { + waitUntil?.(flush(2000)); + return res; + }, + (e: unknown) => { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + }, + ); + } else { + waitUntil?.(flush(2000)); + return result; + } + } catch (e) { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + } + }); + }; + + return sentryWithScope(wrappedFunction); + }, + }); +} diff --git a/packages/cloudflare/test/copy-execution-context.test.ts b/packages/cloudflare/test/instrumentContext.test.ts similarity index 67% rename from packages/cloudflare/test/copy-execution-context.test.ts rename to packages/cloudflare/test/instrumentContext.test.ts index 670d67f2f490..6cca64fd4bb1 100644 --- a/packages/cloudflare/test/copy-execution-context.test.ts +++ b/packages/cloudflare/test/instrumentContext.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, type Mocked, vi } from 'vitest'; -import { copyExecutionContext } from '../src/utils/copyExecutionContext'; +import { instrumentContext } from '../src/utils/instrumentContext'; -describe('Copy of the execution context', () => { +describe('instrumentContext', () => { describe.for([ 'waitUntil', 'passThroughOnException', @@ -15,19 +15,19 @@ describe('Copy of the execution context', () => { const context = { [method]: vi.fn(), } as any; - const copy = copyExecutionContext(context); - copy[method] = vi.fn(); - expect(context[method]).not.toBe(copy[method]); + const instrumented = instrumentContext(context); + instrumented[method] = vi.fn(); + expect(context[method]).not.toBe(instrumented[method]); }); it('Overridden method was called', async () => { const context = { [method]: vi.fn(), } as any; - const copy = copyExecutionContext(context); + const instrumented = instrumentContext(context); const overridden = vi.fn(); - copy[method] = overridden; - copy[method](); + instrumented[method] = overridden; + instrumented[method](); expect(overridden).toBeCalled(); expect(context[method]).not.toBeCalled(); }); @@ -35,7 +35,7 @@ describe('Copy of the execution context', () => { it('No side effects', async () => { const context = makeExecutionContextMock(); - expect(() => copyExecutionContext(Object.freeze(context))).not.toThrow( + expect(() => instrumentContext(Object.freeze(context))).not.toThrow( /Cannot define property \w+, object is not extensible/, ); }); @@ -43,8 +43,8 @@ describe('Copy of the execution context', () => { const s = Symbol('test'); const context = makeExecutionContextMock(); context[s] = {}; - const copy = copyExecutionContext(context); - expect(copy[s]).toBe(context[s]); + const instrumented = instrumentContext(context); + expect(instrumented[s]).toBe(context[s]); }); });