-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(nextjs): Add edge route and middleware wrappers (#6771)
- Loading branch information
Showing
9 changed files
with
383 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<any>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<H extends EdgeRouteHandler>( | ||
handler: H, | ||
options: { spanDescription: string; spanOp: string; mechanismFunctionName: string }, | ||
): (...params: Parameters<H>) => Promise<ReturnType<H>> { | ||
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<H> = 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); | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<boolean> { | ||
const client = getCurrentHub().getClient<Client>(); | ||
if (client) { | ||
return client.flush(timeout); | ||
} | ||
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.'); | ||
return Promise.resolve(false); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<H extends EdgeRouteHandler>( | ||
handler: H, | ||
parameterizedRoute: string, | ||
): (...params: Parameters<H>) => Promise<ReturnType<H>> { | ||
return async function (this: unknown, ...args: Parameters<H>): Promise<ReturnType<H>> { | ||
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); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<H extends EdgeRouteHandler>( | ||
middleware: H, | ||
): (...params: Parameters<H>) => Promise<ReturnType<H>> { | ||
return withEdgeWrapping(middleware, { | ||
spanDescription: 'middleware', | ||
spanOp: 'middleware.nextjs', | ||
mechanismFunctionName: 'withSentryMiddleware', | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
Oops, something went wrong.