-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(nextjs): Add edge route and middleware wrappers #6771
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6f4b028
feat(nextjs): Add edge route and middleware wrappers
lforst a7c103d
Fix tests on node 10
lforst 924850f
s/spanLabel/spanDescription/
lforst 1835aae
Pass in mechanism processor to captureException scope
lforst e40883a
Fix tests
lforst 76e4815
Add unit tests
lforst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: Could we get some unit tests for this logic? In particular for the dynamic nature of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 76e4815 |
||
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
l: Should we use
unknown
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, we have to go with any here because making it unknown would cause TS errors when people pass in their original functions into the wrappers.