diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 42b48094d966..b0ad8ec23901 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -119,23 +119,6 @@ export async function close(timeout?: number): Promise { return Promise.resolve(false); } -/** - * Call `flush()` on the current client, if there is one. See {@link Client.flush}. - * - * @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause - * the client to wait until all events are sent before resolving the promise. - * @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it - * doesn't (or if there's no client defined). - */ -export async function flush(timeout?: number): Promise { - const client = getCurrentHub().getClient(); - if (client) { - return client.flush(timeout); - } - __DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.'); - return Promise.resolve(false); -} - /** * This is the getter for lastEventId. * @@ -145,4 +128,8 @@ export function lastEventId(): string | undefined { return getCurrentHub().lastEventId(); } +export { flush } from './utils/flush'; + export * from '@sentry/core'; +export { withSentryAPI } from './withSentryAPI'; +export { withSentryMiddleware } from './withSentryMiddleware'; diff --git a/packages/nextjs/src/edge/types.ts b/packages/nextjs/src/edge/types.ts new file mode 100644 index 000000000000..81bc6796d6e0 --- /dev/null +++ b/packages/nextjs/src/edge/types.ts @@ -0,0 +1,5 @@ +// We cannot make any assumptions about what users define as their handler except maybe that it is a function +export interface EdgeRouteHandler { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req: any): any | Promise; +} diff --git a/packages/nextjs/src/edge/utils/edgeWrapperUtils.ts b/packages/nextjs/src/edge/utils/edgeWrapperUtils.ts new file mode 100644 index 000000000000..4727c1867d9c --- /dev/null +++ b/packages/nextjs/src/edge/utils/edgeWrapperUtils.ts @@ -0,0 +1,104 @@ +import { captureException, getCurrentHub, startTransaction } from '@sentry/core'; +import { hasTracingEnabled } from '@sentry/tracing'; +import type { Span } from '@sentry/types'; +import { + addExceptionMechanism, + baggageHeaderToDynamicSamplingContext, + extractTraceparentData, + logger, + objectify, +} from '@sentry/utils'; + +import type { EdgeRouteHandler } from '../types'; +import { flush } from './flush'; + +/** + * Wraps a function on the edge runtime with error and performance monitoring. + */ +export function withEdgeWrapping( + handler: H, + options: { spanDescription: string; spanOp: string; mechanismFunctionName: string }, +): (...params: Parameters) => Promise> { + return async function (this: unknown, ...args) { + const req = args[0]; + const currentScope = getCurrentHub().getScope(); + const prevSpan = currentScope?.getSpan(); + + let span: Span | undefined; + + if (hasTracingEnabled()) { + if (prevSpan) { + span = prevSpan.startChild({ + description: options.spanDescription, + op: options.spanOp, + }); + } else if (req instanceof Request) { + // If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision) + let traceparentData; + + const sentryTraceHeader = req.headers.get('sentry-trace'); + if (sentryTraceHeader) { + traceparentData = extractTraceparentData(sentryTraceHeader); + __DEBUG_BUILD__ && logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`); + } + + const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(req.headers.get('baggage')); + + span = startTransaction( + { + name: options.spanDescription, + op: options.spanOp, + ...traceparentData, + metadata: { + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + source: 'route', + }, + }, + // extra context passed to the `tracesSampler` + { request: req }, + ); + } + + currentScope?.setSpan(span); + } + + try { + const handlerResult: ReturnType = await handler.apply(this, args); + + if ((handlerResult as unknown) instanceof Response) { + span?.setHttpStatus(handlerResult.status); + } else { + span?.setStatus('ok'); + } + + return handlerResult; + } catch (e) { + // 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); + + span?.setStatus('internal_error'); + + captureException(objectifiedErr, scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + type: 'instrument', + handled: false, + data: { + function: options.mechanismFunctionName, + }, + }); + return event; + }); + + return scope; + }); + + throw objectifiedErr; + } finally { + span?.finish(); + currentScope?.setSpan(prevSpan); + await flush(2000); + } + }; +} diff --git a/packages/nextjs/src/edge/utils/flush.ts b/packages/nextjs/src/edge/utils/flush.ts new file mode 100644 index 000000000000..5daa52936391 --- /dev/null +++ b/packages/nextjs/src/edge/utils/flush.ts @@ -0,0 +1,20 @@ +import { getCurrentHub } from '@sentry/core'; +import type { Client } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +/** + * Call `flush()` on the current client, if there is one. See {@link Client.flush}. + * + * @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause + * the client to wait until all events are sent before resolving the promise. + * @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it + * doesn't (or if there's no client defined). + */ +export async function flush(timeout?: number): Promise { + const client = getCurrentHub().getClient(); + if (client) { + return client.flush(timeout); + } + __DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.'); + return Promise.resolve(false); +} diff --git a/packages/nextjs/src/edge/withSentryAPI.ts b/packages/nextjs/src/edge/withSentryAPI.ts new file mode 100644 index 000000000000..d18deac1c3c4 --- /dev/null +++ b/packages/nextjs/src/edge/withSentryAPI.ts @@ -0,0 +1,29 @@ +import { getCurrentHub } from '@sentry/core'; + +import type { EdgeRouteHandler } from './types'; +import { withEdgeWrapping } from './utils/edgeWrapperUtils'; + +/** + * Wraps a Next.js edge route handler with Sentry error and performance instrumentation. + */ +export function withSentryAPI( + handler: H, + parameterizedRoute: string, +): (...params: Parameters) => Promise> { + return async function (this: unknown, ...args: Parameters): Promise> { + const req = args[0]; + + const activeSpan = !!getCurrentHub().getScope()?.getSpan(); + + const wrappedHandler = withEdgeWrapping(handler, { + spanDescription: + activeSpan || !(req instanceof Request) + ? `handler (${parameterizedRoute})` + : `${req.method} ${parameterizedRoute}`, + spanOp: activeSpan ? 'function' : 'http.server', + mechanismFunctionName: 'withSentryAPI', + }); + + return await wrappedHandler.apply(this, args); + }; +} diff --git a/packages/nextjs/src/edge/withSentryMiddleware.ts b/packages/nextjs/src/edge/withSentryMiddleware.ts new file mode 100644 index 000000000000..74dd6619ed62 --- /dev/null +++ b/packages/nextjs/src/edge/withSentryMiddleware.ts @@ -0,0 +1,15 @@ +import type { EdgeRouteHandler } from './types'; +import { withEdgeWrapping } from './utils/edgeWrapperUtils'; + +/** + * Wraps Next.js middleware with Sentry error and performance instrumentation. + */ +export function withSentryMiddleware( + middleware: H, +): (...params: Parameters) => Promise> { + return withEdgeWrapping(middleware, { + spanDescription: 'middleware', + spanOp: 'middleware.nextjs', + mechanismFunctionName: 'withSentryMiddleware', + }); +} diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index fcce6708a293..9f82be80a67c 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -29,3 +29,10 @@ export declare function close(timeout?: number | undefined): PromiseLike; export declare function lastEventId(): string | undefined; export declare function getSentryRelease(fallback?: string): string | undefined; + +export declare function withSentryAPI any>( + handler: APIHandler, + parameterizedRoute: string, +): ( + ...args: Parameters +) => ReturnType extends Promise ? ReturnType : Promise>; diff --git a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts new file mode 100644 index 000000000000..f2fd71a10a31 --- /dev/null +++ b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts @@ -0,0 +1,104 @@ +import * as coreSdk from '@sentry/core'; +import * as sentryTracing from '@sentry/tracing'; + +import { withEdgeWrapping } from '../../src/edge/utils/edgeWrapperUtils'; + +// @ts-ignore Request does not exist on type Global +const origRequest = global.Request; +// @ts-ignore Response does not exist on type Global +const origResponse = global.Response; + +// @ts-ignore Request does not exist on type Global +global.Request = class Request { + headers = { + get() { + return null; + }, + }; +}; + +// @ts-ignore Response does not exist on type Global +global.Response = class Request {}; + +afterAll(() => { + // @ts-ignore Request does not exist on type Global + global.Request = origRequest; + // @ts-ignore Response does not exist on type Global + global.Response = origResponse; +}); + +beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.spyOn(sentryTracing, 'hasTracingEnabled').mockImplementation(() => true); +}); + +describe('withEdgeWrapping', () => { + it('should return a function that calls the passed function', async () => { + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(_req => origFunctionReturnValue); + + const wrappedFunction = withEdgeWrapping(origFunction, { + spanDescription: 'some label', + mechanismFunctionName: 'some name', + spanOp: 'some op', + }); + + const returnValue = await wrappedFunction(new Request('https://sentry.io/')); + + expect(returnValue).toBe(origFunctionReturnValue); + expect(origFunction).toHaveBeenCalledTimes(1); + }); + + it('should return a function that calls captureException on error', async () => { + const captureExceptionSpy = jest.spyOn(coreSdk, 'captureException'); + const error = new Error(); + const origFunction = jest.fn(_req => { + throw error; + }); + + const wrappedFunction = withEdgeWrapping(origFunction, { + spanDescription: 'some label', + mechanismFunctionName: 'some name', + spanOp: 'some op', + }); + + await expect(wrappedFunction(new Request('https://sentry.io/'))).rejects.toBe(error); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('should return a function that starts a transaction when a request object is passed', async () => { + const startTransactionSpy = jest.spyOn(coreSdk, 'startTransaction'); + + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(_req => origFunctionReturnValue); + + const wrappedFunction = withEdgeWrapping(origFunction, { + spanDescription: 'some label', + mechanismFunctionName: 'some name', + spanOp: 'some op', + }); + + const request = new Request('https://sentry.io/'); + await wrappedFunction(request); + expect(startTransactionSpy).toHaveBeenCalledTimes(1); + expect(startTransactionSpy).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }), + { request }, + ); + }); + + it("should return a function that doesn't crash when req isn't passed", async () => { + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(() => origFunctionReturnValue); + + const wrappedFunction = withEdgeWrapping(origFunction, { + spanDescription: 'some label', + mechanismFunctionName: 'some name', + spanOp: 'some op', + }); + + await expect(wrappedFunction()).resolves.toBe(origFunctionReturnValue); + expect(origFunction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts new file mode 100644 index 000000000000..aacef0fa3f79 --- /dev/null +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -0,0 +1,95 @@ +import * as coreSdk from '@sentry/core'; +import * as sentryTracing from '@sentry/tracing'; + +import { withSentryAPI } from '../../src/edge'; + +// @ts-ignore Request does not exist on type Global +const origRequest = global.Request; +// @ts-ignore Response does not exist on type Global +const origResponse = global.Response; + +// @ts-ignore Request does not exist on type Global +global.Request = class Request { + headers = { + get() { + return null; + }, + }; + + method = 'POST'; +}; + +// @ts-ignore Response does not exist on type Global +global.Response = class Request {}; + +afterAll(() => { + // @ts-ignore Request does not exist on type Global + global.Request = origRequest; + // @ts-ignore Response does not exist on type Global + global.Response = origResponse; +}); + +beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.spyOn(sentryTracing, 'hasTracingEnabled').mockImplementation(() => true); +}); + +describe('withSentryAPI', () => { + it('should return a function that starts a transaction with the correct name when there is no active transaction and a request is being passed', async () => { + const startTransactionSpy = jest.spyOn(coreSdk, 'startTransaction'); + + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(_req => origFunctionReturnValue); + + const wrappedFunction = withSentryAPI(origFunction, '/user/[userId]/post/[postId]'); + + const request = new Request('https://sentry.io/'); + await wrappedFunction(request); + expect(startTransactionSpy).toHaveBeenCalledTimes(1); + expect(startTransactionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { source: 'route' }, + name: 'POST /user/[userId]/post/[postId]', + op: 'http.server', + }), + { request }, + ); + }); + + it('should return a function that should not start a transaction when there is no active span and no request is being passed', async () => { + const startTransactionSpy = jest.spyOn(coreSdk, 'startTransaction'); + + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(() => origFunctionReturnValue); + + const wrappedFunction = withSentryAPI(origFunction, '/user/[userId]/post/[postId]'); + + await wrappedFunction(); + expect(startTransactionSpy).not.toHaveBeenCalled(); + }); + + it('should return a function that starts a span on the current transaction with the correct description when there is an active transaction and no request is being passed', async () => { + const testTransaction = coreSdk.startTransaction({ name: 'testTransaction' }); + coreSdk.getCurrentHub().getScope()?.setSpan(testTransaction); + + const startChildSpy = jest.spyOn(testTransaction, 'startChild'); + + const origFunctionReturnValue = new Response(); + const origFunction = jest.fn(() => origFunctionReturnValue); + + const wrappedFunction = withSentryAPI(origFunction, '/user/[userId]/post/[postId]'); + + await wrappedFunction(); + expect(startChildSpy).toHaveBeenCalledTimes(1); + expect(startChildSpy).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'handler (/user/[userId]/post/[postId])', + op: 'function', + }), + ); + + testTransaction.finish(); + coreSdk.getCurrentHub().getScope()?.setSpan(undefined); + }); +});