diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts new file mode 100644 index 000000000000..90dda26dac55 --- /dev/null +++ b/packages/sveltekit/src/server/handle.ts @@ -0,0 +1,103 @@ +/* eslint-disable @sentry-internal/sdk/no-optional-chaining */ +import { captureException, getCurrentHub, startTransaction } from '@sentry/node'; +import type { Transaction } from '@sentry/types'; +import { + addExceptionMechanism, + baggageHeaderToDynamicSamplingContext, + extractTraceparentData, + isThenable, + objectify, +} from '@sentry/utils'; +import type { Handle } from '@sveltejs/kit'; +import * as domain from 'domain'; + +function sendErrorToSentry(e: unknown): unknown { + // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can + // store a seen flag on it. + const objectifiedErr = objectify(e); + + captureException(objectifiedErr, scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + type: 'sveltekit', + handled: false, + data: { + function: 'handle', + }, + }); + return event; + }); + + return scope; + }); + + return objectifiedErr; +} + +/** + * A SvelteKit handle function that wraps the request for Sentry error and + * performance monitoring. + * + * Usage: + * ``` + * // src/hooks.server.ts + * import { sentryHandle } from '@sentry/sveltekit'; + * + * export const handle = sentryHandle; + * + * // Optionally use the sequence function to add additional handlers. + * // export const handle = sequence(sentryHandle, yourCustomHandle); + * ``` + */ +export const sentryHandle: Handle = ({ event, resolve }) => { + return domain.create().bind(() => { + let maybePromiseResult; + + const sentryTraceHeader = event.request.headers.get('sentry-trace'); + const baggageHeader = event.request.headers.get('baggage'); + const traceparentData = sentryTraceHeader ? extractTraceparentData(sentryTraceHeader) : undefined; + const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader); + + // transaction could be undefined if hub extensions were not added. + const transaction: Transaction | undefined = startTransaction({ + op: 'http.server', + name: `${event.request.method} ${event.route.id}`, + status: 'ok', + ...traceparentData, + metadata: { + source: 'route', + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + }, + }); + + getCurrentHub().getScope()?.setSpan(transaction); + + try { + maybePromiseResult = resolve(event); + } catch (e) { + transaction?.setStatus('internal_error'); + const sentryError = sendErrorToSentry(e); + transaction?.finish(); + throw sentryError; + } + + if (isThenable(maybePromiseResult)) { + Promise.resolve(maybePromiseResult).then( + response => { + transaction?.setHttpStatus(response.status); + transaction?.finish(); + }, + e => { + transaction?.setStatus('internal_error'); + sendErrorToSentry(e); + transaction?.finish(); + }, + ); + } else { + transaction?.setHttpStatus(maybePromiseResult.status); + transaction?.finish(); + } + + return maybePromiseResult; + })(); +}; diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index c7784d870c56..9109f29499d4 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -3,3 +3,4 @@ export * from '@sentry/node'; export { init } from './sdk'; export { handleErrorWithSentry } from './handleError'; export { wrapLoadWithSentry } from './load'; +export { sentryHandle } from './handle'; diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts new file mode 100644 index 000000000000..cf17b56aaa90 --- /dev/null +++ b/packages/sveltekit/test/server/handle.test.ts @@ -0,0 +1,251 @@ +import { addTracingExtensions, Hub, makeMain, Scope } from '@sentry/core'; +import { NodeClient } from '@sentry/node'; +import type { Transaction } from '@sentry/types'; +import type { Handle } from '@sveltejs/kit'; +import { vi } from 'vitest'; + +import { sentryHandle } from '../../src/server/handle'; +import { getDefaultNodeClientOptions } from '../utils'; + +const mockCaptureException = vi.fn(); +let mockScope = new Scope(); + +vi.mock('@sentry/node', async () => { + const original = (await vi.importActual('@sentry/node')) as any; + return { + ...original, + captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { + cb(mockScope); + mockCaptureException(err, cb); + return original.captureException(err, cb); + }, + }; +}); + +const mockAddExceptionMechanism = vi.fn(); + +vi.mock('@sentry/utils', async () => { + const original = (await vi.importActual('@sentry/utils')) as any; + return { + ...original, + addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), + }; +}); + +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): Parameters[0]['resolve'] { + 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 hub: Hub; +let client: NodeClient; + +describe('handleSentry', () => { + beforeAll(() => { + addTracingExtensions(); + }); + + beforeEach(() => { + mockScope = new Scope(); + const options = getDefaultNodeClientOptions({ tracesSampleRate: 1.0 }); + client = new NodeClient(options); + hub = new Hub(client); + makeMain(hub); + + mockCaptureException.mockClear(); + mockAddExceptionMechanism.mockClear(); + }); + + 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', async () => { + let ref: any = undefined; + client.on('finishTransaction', (transaction: Transaction) => { + ref = transaction; + }); + + try { + await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(ref).toBeDefined(); + + expect(ref.name).toEqual('GET /users/[id]'); + expect(ref.op).toEqual('http.server'); + expect(ref.status).toEqual(isError ? 'internal_error' : 'ok'); + expect(ref.metadata.source).toEqual('route'); + + expect(ref.endTimestamp).toBeDefined(); + }); + + it('creates a transaction from sentry-trace header', async () => { + const event = mockEvent({ + request: { + headers: { + get: (key: string) => { + if (key === 'sentry-trace') { + return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; + } + + return null; + }, + }, + }, + }); + + let ref: any = undefined; + client.on('finishTransaction', (transaction: Transaction) => { + ref = transaction; + }); + + try { + await sentryHandle({ event, resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(ref).toBeDefined(); + expect(ref.traceId).toEqual('1234567890abcdef1234567890abcdef'); + expect(ref.parentSpanId).toEqual('1234567890abcdef'); + expect(ref.sampled).toEqual(true); + }); + + 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-user_segment=segmentA,sentry-public_key=dogsarebadatkeepingsecrets,' + + 'sentry-trace_id=1234567890abcdef1234567890abcdef,sentry-sample_rate=1' + ); + } + + return null; + }, + }, + }, + }); + + let ref: any = undefined; + client.on('finishTransaction', (transaction: Transaction) => { + ref = transaction; + }); + + try { + await sentryHandle({ event, resolve: resolve(type, isError) }); + } catch (e) { + // + } + + expect(ref).toBeDefined(); + expect(ref.metadata.dynamicSamplingContext).toEqual({ + environment: 'production', + release: '1.0.0', + public_key: 'dogsarebadatkeepingsecrets', + sample_rate: '1', + trace_id: '1234567890abcdef1234567890abcdef', + transaction: 'dogpark', + user_segment: 'segmentA', + }); + }); + + it('send errors to Sentry', async () => { + const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { + void callback({}, { event_id: 'fake-event-id' }); + return mockScope; + }); + + try { + await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) }); + } catch (e) { + expect(mockCaptureException).toBeCalledTimes(1); + expect(addEventProcessorSpy).toBeCalledTimes(1); + expect(mockAddExceptionMechanism).toBeCalledTimes(1); + expect(mockAddExceptionMechanism).toBeCalledWith( + {}, + { handled: false, type: 'sveltekit', data: { function: 'handle' } }, + ); + } + }); + }); +}); diff --git a/packages/sveltekit/test/utils.ts b/packages/sveltekit/test/utils.ts new file mode 100644 index 000000000000..993a6bd8823d --- /dev/null +++ b/packages/sveltekit/test/utils.ts @@ -0,0 +1,12 @@ +import { createTransport } from '@sentry/core'; +import type { ClientOptions } from '@sentry/types'; +import { resolvedSyncPromise } from '@sentry/utils'; + +export function getDefaultNodeClientOptions(options: Partial = {}): ClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + ...options, + }; +}