diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index ba00778bded5..d595ccfa5985 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -13,6 +13,7 @@ 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'; @@ -140,7 +141,7 @@ export function instrumentDurableObjectWithSentry> construct(target, [context, env]) { setAsyncLocalStorageAsyncContextStrategy(); - const options = optionsCallback(env); + const options = getFinalOptions(optionsCallback(env), env); const obj = new target(context, env); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index f2bb059ee071..7e1667d6dc56 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -9,6 +9,7 @@ import { import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; +import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; @@ -35,7 +36,9 @@ export function withSentry>) { const [request, env, context] = args; - const options = optionsCallback(env); + + const options = getFinalOptions(optionsCallback(env), env); + return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); }, }); @@ -48,7 +51,8 @@ export function withSentry>) { const [event, env, context] = args; return withIsolationScope(isolationScope => { - const options = optionsCallback(env); + const options = getFinalOptions(optionsCallback(env), env); + const client = init(options); isolationScope.setClient(client); diff --git a/packages/cloudflare/src/options.ts b/packages/cloudflare/src/options.ts new file mode 100644 index 000000000000..77a37ea51d31 --- /dev/null +++ b/packages/cloudflare/src/options.ts @@ -0,0 +1,20 @@ +import type { CloudflareOptions } from './client'; + +/** + * Merges the options passed in from the user with the options we read from + * the Cloudflare `env` environment variable object. + * + * @param userOptions - The options passed in from the user. + * @param env - The environment variables. + * + * @returns The final options. + */ +export function getFinalOptions(userOptions: CloudflareOptions, env: unknown): CloudflareOptions { + if (typeof env !== 'object' || env === null) { + return userOptions; + } + + const release = 'SENTRY_RELEASE' in env && typeof env.SENTRY_RELEASE === 'string' ? env.SENTRY_RELEASE : undefined; + + return { release, ...userOptions }; +} diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 4d9f1c1d7903..602df308c3df 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -10,6 +10,7 @@ import { withSentry } from '../src/handler'; const MOCK_ENV = { SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', }; describe('withSentry', () => { @@ -51,6 +52,65 @@ describe('withSentry', () => { expect(result).toBe(response); }); + + test('merges options from env and callback', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('2.0.0'); + }); }); describe('scheduled handler', () => { @@ -70,6 +130,55 @@ describe('withSentry', () => { expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); }); + test('merges options from env and callback', async () => { + const handler = { + scheduled(_controller, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + scheduled(_controller, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { const handler = { scheduled(_controller, _env, _context) { diff --git a/packages/cloudflare/test/options.test.ts b/packages/cloudflare/test/options.test.ts new file mode 100644 index 000000000000..ae8a5509b233 --- /dev/null +++ b/packages/cloudflare/test/options.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { getFinalOptions } from '../src/options'; + +describe('getFinalOptions', () => { + it('returns user options when env is not an object', () => { + const userOptions = { dsn: 'test-dsn', release: 'test-release' }; + const env = 'not-an-object'; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('returns user options when env is null', () => { + const userOptions = { dsn: 'test-dsn', release: 'test-release' }; + const env = null; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('merges options from env with user options', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 'env-release' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual({ dsn: 'test-dsn', release: 'user-release' }); + }); + + it('uses user options when SENTRY_RELEASE exists but is not a string', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 123 }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('uses user options when SENTRY_RELEASE does not exist', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { OTHER_VAR: 'some-value' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('takes user options over env options', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 'env-release' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); +});