diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts index 462912b8756f..d76cdbc4c698 100644 --- a/packages/cloudflare/src/async.ts +++ b/packages/cloudflare/src/async.ts @@ -10,6 +10,9 @@ import { AsyncLocalStorage } from 'node:async_hooks'; * * AsyncLocalStorage is only available in the cloudflare workers runtime if you set * compatibility_flags = ["nodejs_compat"] or compatibility_flags = ["nodejs_als"] + * + * @internal Only exported to be used in higher-level Sentry packages + * @hidden Only exported to be used in higher-level Sentry packages */ export function setAsyncLocalStorageAsyncContextStrategy(): void { const asyncStorage = new AsyncLocalStorage<{ diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index cd3dda5924ec..05fd40fb4c96 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -98,3 +98,5 @@ export { getDefaultIntegrations } from './sdk'; export { fetchIntegration } from './integrations/fetch'; export { instrumentD1WithSentry } from './d1'; + +export { setAsyncLocalStorageAsyncContextStrategy } from './async'; diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 0d26c566ea10..618c6503af36 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -1,4 +1,8 @@ -import { type CloudflareOptions, wrapRequestHandler } from '@sentry/cloudflare'; +import { + type CloudflareOptions, + wrapRequestHandler, + setAsyncLocalStorageAsyncContextStrategy, +} from '@sentry/cloudflare'; import { getDefaultIntegrations as getDefaultCloudflareIntegrations } from '@sentry/cloudflare'; import type { Handle } from '@sveltejs/kit'; @@ -17,6 +21,8 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { ...options, }; + setAsyncLocalStorageAsyncContextStrategy(); + const handleInitSentry: Handle = ({ event, resolve }) => { // if event.platform exists (should be there in a cloudflare worker), then do the cloudflare sentry init if (event.platform) { diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts new file mode 100644 index 000000000000..9c6e2b71d330 --- /dev/null +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -0,0 +1,451 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getRootSpan, + getSpanDescendants, + spanIsSampled, + spanToJSON, +} from '@sentry/core'; +import type { EventEnvelopeHeaders, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { NodeClient, setCurrentClient } from '@sentry/node'; +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { vi } from 'vitest'; + +import { FETCH_PROXY_SCRIPT, addSentryCodeToPage, isFetchProxyRequired } from '../../src/server-common/handle'; +import { sentryHandle } from '../../src/server-common/handle'; +import { getDefaultNodeClientOptions } from '../utils'; + +const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx'); + +function mockEvent(override: Record = {}): Parameters[0]['event'] { + const event: Parameters[0]['event'] = { + cookies: {} as any, + fetch: () => Promise.resolve({} as any), + getClientAddress: () => '', + locals: {}, + params: { id: '123' }, + platform: {}, + request: { + method: 'GET', + headers: { + get: () => null, + append: () => {}, + delete: () => {}, + forEach: () => {}, + has: () => false, + set: () => {}, + }, + } as any, + route: { id: '/users/[id]' }, + setHeaders: () => {}, + url: new URL('http://localhost:3000/users/123'), + isDataRequest: false, + + ...override, + }; + + return event; +} + +const mockResponse = { status: 200, headers: {}, body: '' } as any; + +const enum Type { + Sync = 'sync', + Async = 'async', +} + +function resolve( + type: Type, + isError: boolean, + throwSpecialError?: 'redirect' | 'http', +): Parameters[0]['resolve'] { + if (throwSpecialError === 'redirect') { + throw redirect(302, '/redirect'); + } + if (throwSpecialError === 'http') { + throw { status: 404, body: 'Not found' }; + } + + if (type === Type.Sync) { + return (..._args: unknown[]) => { + if (isError) { + throw new Error(type); + } + + return mockResponse; + }; + } + + return (..._args: unknown[]) => { + return new Promise((resolve, reject) => { + if (isError) { + reject(new Error(type)); + } else { + resolve(mockResponse); + } + }); + }; +} + +let client: NodeClient; + +beforeEach(() => { + const options = getDefaultNodeClientOptions({ tracesSampleRate: 1.0 }); + client = new NodeClient(options); + setCurrentClient(client); + client.init(); + + mockCaptureException.mockClear(); + vi.clearAllMocks(); +}); + +describe('sentryHandle', () => { + describe.each([ + // isSync, isError, expectedResponse + [Type.Sync, true, undefined], + [Type.Sync, false, mockResponse], + [Type.Async, true, undefined], + [Type.Async, false, mockResponse], + ])('%s resolve with error %s', (type, isError, mockResponse) => { + it('should return a response', async () => { + let response: any = undefined; + try { + response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e.message).toEqual(type); + } + + expect(response).toEqual(mockResponse); + }); + + it("creates a transaction if there's no active span", async () => { + let _span: Span | undefined = undefined; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + } + }); + + try { + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(_span!).toBeDefined(); + + expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); + expect(spanToJSON(_span!).op).toEqual('http.server'); + expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); + expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); + + expect(spanToJSON(_span!).timestamp).toBeDefined(); + + const spans = getSpanDescendants(_span!); + expect(spans).toHaveLength(1); + }); + + it('creates a child span for nested server calls (i.e. if there is an active span)', async () => { + let _span: Span | undefined = undefined; + let txnCount = 0; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + ++txnCount; + } + }); + + try { + await sentryHandle()({ + event: mockEvent(), + resolve: async _ => { + // simulating a nested load call: + await sentryHandle()({ + event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }), + resolve: resolve(type, isError), + }); + return mockResponse; + }, + }); + } catch (e) { + // + } + + expect(txnCount).toEqual(1); + expect(_span!).toBeDefined(); + + expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); + expect(spanToJSON(_span!).op).toEqual('http.server'); + expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); + expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); + + expect(spanToJSON(_span!).timestamp).toBeDefined(); + + const spans = getSpanDescendants(_span!).map(spanToJSON); + + expect(spans).toHaveLength(2); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: 'http.server', description: 'GET /users/[id]' }), + expect.objectContaining({ op: 'http.server', description: 'GET api/users/details/[id]' }), + ]), + ); + }); + + it("creates a transaction from sentry-trace header but doesn't populate a new DSC", async () => { + const event = mockEvent({ + request: { + headers: { + get: (key: string) => { + if (key === 'sentry-trace') { + return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; + } + + return null; + }, + }, + }, + }); + + let _span: Span | undefined = undefined; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + } + }); + + let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; + client.on('beforeEnvelope', env => { + envelopeHeaders = env[0] as EventEnvelopeHeaders; + }); + + try { + await sentryHandle()({ event, resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(_span).toBeDefined(); + expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); + expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); + expect(spanIsSampled(_span!)).toEqual(true); + expect(envelopeHeaders!.trace).toEqual({}); + }); + + it('creates a transaction with dynamic sampling context from baggage header', async () => { + const event = mockEvent({ + request: { + headers: { + get: (key: string) => { + if (key === 'sentry-trace') { + return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; + } + + if (key === 'baggage') { + return ( + 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=dogpark,' + + 'sentry-public_key=dogsarebadatkeepingsecrets,' + + 'sentry-trace_id=1234567890abcdef1234567890abcdef,sentry-sample_rate=1,' + + 'sentry-sample_rand=0.42' + ); + } + + return null; + }, + }, + }, + }); + + let _span: Span | undefined = undefined; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + } + }); + + let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; + client.on('beforeEnvelope', env => { + envelopeHeaders = env[0] as EventEnvelopeHeaders; + }); + + try { + await sentryHandle()({ event, resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(_span!).toBeDefined(); + expect(envelopeHeaders!.trace).toEqual({ + environment: 'production', + release: '1.0.0', + public_key: 'dogsarebadatkeepingsecrets', + sample_rate: '1', + trace_id: '1234567890abcdef1234567890abcdef', + transaction: 'dogpark', + sample_rand: '0.42', + }); + }); + + it('send errors to Sentry', async () => { + try { + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); + } catch (e) { + expect(mockCaptureException).toBeCalledTimes(1); + expect(mockCaptureException).toBeCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'sveltekit', data: { function: 'handle' } }, + }); + } + }); + + it("doesn't send redirects in a request handler to Sentry", async () => { + try { + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, false, 'redirect') }); + } catch (e) { + expect(mockCaptureException).toBeCalledTimes(0); + } + }); + + it("doesn't send Http 4xx errors in a request handler to Sentry", async () => { + try { + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, false, 'http') }); + } catch (e) { + expect(mockCaptureException).toBeCalledTimes(0); + } + }); + + it('calls `transformPageChunk`', async () => { + const mockResolve = vi.fn().mockImplementation(resolve(type, isError)); + const event = mockEvent(); + try { + await sentryHandle()({ event, resolve: mockResolve }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + // @ts-expect-error - this is fine + expect(e.message).toEqual(type); + } + + expect(mockResolve).toHaveBeenCalledTimes(1); + expect(mockResolve).toHaveBeenCalledWith(event, { transformPageChunk: expect.any(Function) }); + }); + + it("doesn't create a transaction if there's no route", async () => { + let _span: Span | undefined = undefined; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + } + }); + + try { + await sentryHandle()({ event: mockEvent({ route: undefined }), resolve: resolve(type, isError) }); + } catch { + // + } + + expect(_span!).toBeUndefined(); + }); + + it("Creates a transaction if there's no route but `handleUnknownRequests` is true", async () => { + let _span: Span | undefined = undefined; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + } + }); + + try { + await sentryHandle({ handleUnknownRoutes: true })({ + event: mockEvent({ route: undefined }), + resolve: resolve(type, isError), + }); + } catch { + // + } + + expect(_span!).toBeDefined(); + }); + + it("doesn't create an isolation scope when the `_sentrySkipRequestIsolation` local is set", async () => { + const withIsolationScopeSpy = vi.spyOn(SentryCore, 'withIsolationScope'); + const continueTraceSpy = vi.spyOn(SentryCore, 'continueTrace'); + + try { + await sentryHandle({ handleUnknownRoutes: true })({ + event: { ...mockEvent({ route: undefined }), locals: { _sentrySkipRequestIsolation: true } }, + resolve: resolve(type, isError), + }); + } catch { + // + } + + expect(withIsolationScopeSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('addSentryCodeToPage', () => { + const html = ` + + + + + + + +
%sveltekit.body%
+ + `; + + it("Adds add meta tags and fetch proxy script if there's no active transaction", () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); + const transformed = transformPageChunk({ html, done: true }); + + expect(transformed).toContain(' { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); + SentryCore.startSpan({ name: 'test' }, () => { + const transformed = transformPageChunk({ html, done: true }) as string; + + expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); + }); + }); + + it('does not add the fetch proxy script if the `injectFetchProxyScript` option is false', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); + const transformed = transformPageChunk({ html, done: true }) as string; + + expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); + }); +}); + +describe('isFetchProxyRequired', () => { + it.each(['2.16.0', '2.16.1', '2.17.0', '3.0.0', '3.0.0-alpha.0'])( + 'returns false if the version is greater than or equal to 2.16.0 (%s)', + version => { + expect(isFetchProxyRequired(version)).toBe(false); + }, + ); + + it.each(['2.15.0', '2.15.1', '1.30.0', '1.0.0'])('returns true if the version is lower than 2.16.0 (%s)', version => { + expect(isFetchProxyRequired(version)).toBe(true); + }); + + it.each(['invalid', 'a.b.c'])('returns true for an invalid version (%s)', version => { + expect(isFetchProxyRequired(version)).toBe(true); + }); +}); diff --git a/packages/sveltekit/test/server/handleError.test.ts b/packages/sveltekit/test/server-common/handleError.test.ts similarity index 100% rename from packages/sveltekit/test/server/handleError.test.ts rename to packages/sveltekit/test/server-common/handleError.test.ts diff --git a/packages/sveltekit/test/server/load.test.ts b/packages/sveltekit/test/server-common/load.test.ts similarity index 100% rename from packages/sveltekit/test/server/load.test.ts rename to packages/sveltekit/test/server-common/load.test.ts diff --git a/packages/sveltekit/test/server/rewriteFramesIntegration.test.ts b/packages/sveltekit/test/server-common/rewriteFramesIntegration.test.ts similarity index 100% rename from packages/sveltekit/test/server/rewriteFramesIntegration.test.ts rename to packages/sveltekit/test/server-common/rewriteFramesIntegration.test.ts diff --git a/packages/sveltekit/test/server/sdk.test.ts b/packages/sveltekit/test/server-common/sdk.test.ts similarity index 100% rename from packages/sveltekit/test/server/sdk.test.ts rename to packages/sveltekit/test/server-common/sdk.test.ts diff --git a/packages/sveltekit/test/server/serverRoute.test.ts b/packages/sveltekit/test/server-common/serverRoute.test.ts similarity index 100% rename from packages/sveltekit/test/server/serverRoute.test.ts rename to packages/sveltekit/test/server-common/serverRoute.test.ts diff --git a/packages/sveltekit/test/server/utils.test.ts b/packages/sveltekit/test/server-common/utils.test.ts similarity index 100% rename from packages/sveltekit/test/server/utils.test.ts rename to packages/sveltekit/test/server-common/utils.test.ts diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index 9c6e2b71d330..f29a9d840bc3 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -1,451 +1,33 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - getRootSpan, - getSpanDescendants, - spanIsSampled, - spanToJSON, -} from '@sentry/core'; -import type { EventEnvelopeHeaders, Span } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import { NodeClient, setCurrentClient } from '@sentry/node'; -import type { Handle } from '@sveltejs/kit'; -import { redirect } from '@sveltejs/kit'; -import { vi } from 'vitest'; +import { sentryHandle, initCloudflareSentryHandle } from '../../src/server'; -import { FETCH_PROXY_SCRIPT, addSentryCodeToPage, isFetchProxyRequired } from '../../src/server-common/handle'; -import { sentryHandle } from '../../src/server-common/handle'; -import { getDefaultNodeClientOptions } from '../utils'; +import * as NodeSDK from '@sentry/node'; -const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx'); - -function mockEvent(override: Record = {}): Parameters[0]['event'] { - const event: Parameters[0]['event'] = { - cookies: {} as any, - fetch: () => Promise.resolve({} as any), - getClientAddress: () => '', - locals: {}, - params: { id: '123' }, - platform: {}, - request: { - method: 'GET', - headers: { - get: () => null, - append: () => {}, - delete: () => {}, - forEach: () => {}, - has: () => false, - set: () => {}, - }, - } as any, - route: { id: '/users/[id]' }, - setHeaders: () => {}, - url: new URL('http://localhost:3000/users/123'), - isDataRequest: false, - - ...override, - }; - - return event; -} - -const mockResponse = { status: 200, headers: {}, body: '' } as any; - -const enum Type { - Sync = 'sync', - Async = 'async', -} - -function resolve( - type: Type, - isError: boolean, - throwSpecialError?: 'redirect' | 'http', -): Parameters[0]['resolve'] { - if (throwSpecialError === 'redirect') { - throw redirect(302, '/redirect'); - } - if (throwSpecialError === 'http') { - throw { status: 404, body: 'Not found' }; - } - - if (type === Type.Sync) { - return (..._args: unknown[]) => { - if (isError) { - throw new Error(type); - } - - return mockResponse; - }; - } - - return (..._args: unknown[]) => { - return new Promise((resolve, reject) => { - if (isError) { - reject(new Error(type)); - } else { - resolve(mockResponse); - } - }); - }; -} - -let client: NodeClient; - -beforeEach(() => { - const options = getDefaultNodeClientOptions({ tracesSampleRate: 1.0 }); - client = new NodeClient(options); - setCurrentClient(client); - client.init(); - - mockCaptureException.mockClear(); - vi.clearAllMocks(); -}); - -describe('sentryHandle', () => { - describe.each([ - // isSync, isError, expectedResponse - [Type.Sync, true, undefined], - [Type.Sync, false, mockResponse], - [Type.Async, true, undefined], - [Type.Async, false, mockResponse], - ])('%s resolve with error %s', (type, isError, mockResponse) => { - it('should return a response', async () => { - let response: any = undefined; - try { - response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.message).toEqual(type); - } - - expect(response).toEqual(mockResponse); - }); - - it("creates a transaction if there's no active span", async () => { - let _span: Span | undefined = undefined; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - } - }); - - try { - await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); - } catch (e) { - // - } - - expect(_span!).toBeDefined(); - - expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); - expect(spanToJSON(_span!).op).toEqual('http.server'); - expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); - expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); - - expect(spanToJSON(_span!).timestamp).toBeDefined(); - - const spans = getSpanDescendants(_span!); - expect(spans).toHaveLength(1); - }); - - it('creates a child span for nested server calls (i.e. if there is an active span)', async () => { - let _span: Span | undefined = undefined; - let txnCount = 0; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - ++txnCount; - } - }); - - try { - await sentryHandle()({ - event: mockEvent(), - resolve: async _ => { - // simulating a nested load call: - await sentryHandle()({ - event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }), - resolve: resolve(type, isError), - }); - return mockResponse; - }, - }); - } catch (e) { - // - } - - expect(txnCount).toEqual(1); - expect(_span!).toBeDefined(); - - expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); - expect(spanToJSON(_span!).op).toEqual('http.server'); - expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); - expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); - - expect(spanToJSON(_span!).timestamp).toBeDefined(); - - const spans = getSpanDescendants(_span!).map(spanToJSON); - - expect(spans).toHaveLength(2); - expect(spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ op: 'http.server', description: 'GET /users/[id]' }), - expect.objectContaining({ op: 'http.server', description: 'GET api/users/details/[id]' }), - ]), - ); - }); - - it("creates a transaction from sentry-trace header but doesn't populate a new DSC", async () => { - const event = mockEvent({ - request: { - headers: { - get: (key: string) => { - if (key === 'sentry-trace') { - return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; - } - - return null; - }, - }, - }, - }); - - let _span: Span | undefined = undefined; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - } - }); - - let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; - client.on('beforeEnvelope', env => { - envelopeHeaders = env[0] as EventEnvelopeHeaders; - }); - - try { - await sentryHandle()({ event, resolve: resolve(type, isError) }); - } catch (e) { - // - } - - expect(_span).toBeDefined(); - expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); - expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); - expect(spanIsSampled(_span!)).toEqual(true); - expect(envelopeHeaders!.trace).toEqual({}); - }); - - it('creates a transaction with dynamic sampling context from baggage header', async () => { - const event = mockEvent({ - request: { - headers: { - get: (key: string) => { - if (key === 'sentry-trace') { - return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; - } - - if (key === 'baggage') { - return ( - 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=dogpark,' + - 'sentry-public_key=dogsarebadatkeepingsecrets,' + - 'sentry-trace_id=1234567890abcdef1234567890abcdef,sentry-sample_rate=1,' + - 'sentry-sample_rand=0.42' - ); - } - - return null; - }, - }, - }, - }); - - let _span: Span | undefined = undefined; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - } - }); - - let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; - client.on('beforeEnvelope', env => { - envelopeHeaders = env[0] as EventEnvelopeHeaders; - }); - - try { - await sentryHandle()({ event, resolve: resolve(type, isError) }); - } catch (e) { - // - } - - expect(_span!).toBeDefined(); - expect(envelopeHeaders!.trace).toEqual({ - environment: 'production', - release: '1.0.0', - public_key: 'dogsarebadatkeepingsecrets', - sample_rate: '1', - trace_id: '1234567890abcdef1234567890abcdef', - transaction: 'dogpark', - sample_rand: '0.42', - }); - }); - - it('send errors to Sentry', async () => { - try { - await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); - } catch (e) { - expect(mockCaptureException).toBeCalledTimes(1); - expect(mockCaptureException).toBeCalledWith(expect.any(Error), { - mechanism: { handled: false, type: 'sveltekit', data: { function: 'handle' } }, - }); - } - }); - - it("doesn't send redirects in a request handler to Sentry", async () => { - try { - await sentryHandle()({ event: mockEvent(), resolve: resolve(type, false, 'redirect') }); - } catch (e) { - expect(mockCaptureException).toBeCalledTimes(0); - } - }); - - it("doesn't send Http 4xx errors in a request handler to Sentry", async () => { - try { - await sentryHandle()({ event: mockEvent(), resolve: resolve(type, false, 'http') }); - } catch (e) { - expect(mockCaptureException).toBeCalledTimes(0); - } - }); - - it('calls `transformPageChunk`', async () => { - const mockResolve = vi.fn().mockImplementation(resolve(type, isError)); - const event = mockEvent(); - try { - await sentryHandle()({ event, resolve: mockResolve }); - } catch (e) { - expect(e).toBeInstanceOf(Error); - // @ts-expect-error - this is fine - expect(e.message).toEqual(type); - } - - expect(mockResolve).toHaveBeenCalledTimes(1); - expect(mockResolve).toHaveBeenCalledWith(event, { transformPageChunk: expect.any(Function) }); - }); - - it("doesn't create a transaction if there's no route", async () => { - let _span: Span | undefined = undefined; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - } - }); - - try { - await sentryHandle()({ event: mockEvent({ route: undefined }), resolve: resolve(type, isError) }); - } catch { - // - } - - expect(_span!).toBeUndefined(); - }); - - it("Creates a transaction if there's no route but `handleUnknownRequests` is true", async () => { - let _span: Span | undefined = undefined; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - } - }); - - try { - await sentryHandle({ handleUnknownRoutes: true })({ - event: mockEvent({ route: undefined }), - resolve: resolve(type, isError), - }); - } catch { - // - } - - expect(_span!).toBeDefined(); - }); - - it("doesn't create an isolation scope when the `_sentrySkipRequestIsolation` local is set", async () => { - const withIsolationScopeSpy = vi.spyOn(SentryCore, 'withIsolationScope'); - const continueTraceSpy = vi.spyOn(SentryCore, 'continueTrace'); - - try { - await sentryHandle({ handleUnknownRoutes: true })({ - event: { ...mockEvent({ route: undefined }), locals: { _sentrySkipRequestIsolation: true } }, - resolve: resolve(type, isError), - }); - } catch { - // - } - - expect(withIsolationScopeSpy).not.toHaveBeenCalled(); - expect(continueTraceSpy).not.toHaveBeenCalled(); - }); - }); -}); - -describe('addSentryCodeToPage', () => { - const html = ` - - - - - - - -
%sveltekit.body%
- - `; - - it("Adds add meta tags and fetch proxy script if there's no active transaction", () => { - const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); - const transformed = transformPageChunk({ html, done: true }); - - expect(transformed).toContain(' { + // dumb test to ensure we continue exporting the request handlers + it('should export all handlers from the Node SDK entry point', () => { + expect(sentryHandle).toBeDefined(); + expect(initCloudflareSentryHandle).toBeDefined(); }); - it('adds meta tags and the fetch proxy script if there is an active transaction', () => { - const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); - SentryCore.startSpan({ name: 'test' }, () => { - const transformed = transformPageChunk({ html, done: true }) as string; - - expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); - }); - }); + describe('initCloudflareSentryHandle', () => { + it('inits Sentry on the first call but not on subsequent calls', async () => { + // @ts-expect-error - no need for an actual init call + vi.spyOn(NodeSDK, 'init').mockImplementationOnce(() => {}); - it('does not add the fetch proxy script if the `injectFetchProxyScript` option is false', () => { - const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); - const transformed = transformPageChunk({ html, done: true }) as string; + const handle = initCloudflareSentryHandle({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + expect(handle).toBeDefined(); - expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); - }); -}); + // @ts-expect-error - no need to call with actual params + await handle({ event: {}, resolve: () => Promise.resolve({}) }); -describe('isFetchProxyRequired', () => { - it.each(['2.16.0', '2.16.1', '2.17.0', '3.0.0', '3.0.0-alpha.0'])( - 'returns false if the version is greater than or equal to 2.16.0 (%s)', - version => { - expect(isFetchProxyRequired(version)).toBe(false); - }, - ); + expect(NodeSDK.init).toHaveBeenCalledTimes(1); - it.each(['2.15.0', '2.15.1', '1.30.0', '1.0.0'])('returns true if the version is lower than 2.16.0 (%s)', version => { - expect(isFetchProxyRequired(version)).toBe(true); - }); + // @ts-expect-error - no need to call with actual params + await handle({ event: {}, resolve: () => Promise.resolve({}) }); - it.each(['invalid', 'a.b.c'])('returns true for an invalid version (%s)', version => { - expect(isFetchProxyRequired(version)).toBe(true); + expect(NodeSDK.init).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts new file mode 100644 index 000000000000..c36356cabb5c --- /dev/null +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { initCloudflareSentryHandle } from '../../src/worker'; + +import * as SentryCloudflare from '@sentry/cloudflare'; +import { beforeEach } from 'node:test'; +import type { Carrier, GLOBAL_OBJ } from '@sentry/core'; + +const globalWithSentry = globalThis as typeof GLOBAL_OBJ & Carrier; + +function getHandlerInput() { + const options = { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; + const request = { foo: 'bar' }; + const context = { bar: 'baz' }; + + const event = { request, platform: { context } }; + const resolve = vi.fn(() => Promise.resolve({})); + return { options, event, resolve, request, context }; +} + +describe('initCloudflareSentryHandle', () => { + beforeEach(() => { + delete globalWithSentry.__SENTRY__; + }); + + it('sets the async context strategy when called', () => { + vi.spyOn(SentryCloudflare, 'setAsyncLocalStorageAsyncContextStrategy'); + + initCloudflareSentryHandle({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(SentryCloudflare.setAsyncLocalStorageAsyncContextStrategy).toHaveBeenCalledTimes(1); + expect( + globalWithSentry.__SENTRY__ && globalWithSentry.__SENTRY__[globalWithSentry.__SENTRY__?.version || '']?.acs, + ).toBeDefined(); + }); + + it('calls wrapRequestHandler with the correct arguments', async () => { + const { options, event, resolve, request, context } = getHandlerInput(); + + // @ts-expect-error - resolving an empty object is enough for this test + vi.spyOn(SentryCloudflare, 'wrapRequestHandler').mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle(options); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(SentryCloudflare.wrapRequestHandler).toHaveBeenCalledTimes(1); + expect(SentryCloudflare.wrapRequestHandler).toHaveBeenCalledWith( + { options: expect.objectContaining({ dsn: options.dsn }), request, context }, + expect.any(Function), + ); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('adds flag to skip request isolation in subsequent sentry handler', async () => { + const { options, event, resolve } = getHandlerInput(); + const locals = {}; + + // @ts-expect-error - resolving an empty object is enough for this test + vi.spyOn(SentryCloudflare, 'wrapRequestHandler').mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle(options); + + // @ts-expect-error - only passing a partial event object + await handle({ event: { ...event, locals }, resolve }); + + // @ts-expect-error - this property exists if the handler resolved correctly. + expect(locals._sentrySkipRequestIsolation).toBe(true); + }); + + it('falls back to resolving the event, if no platform data is set', async () => { + const { options, event, resolve } = getHandlerInput(); + // @ts-expect-error - removing platform data + delete event.platform; + + // @ts-expect-error - resolving an empty object is enough for this test + vi.spyOn(SentryCloudflare, 'wrapRequestHandler').mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle(options); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(SentryCloudflare.wrapRequestHandler).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + }); +});