-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(nextjs): Add route handler instrumentation #8832
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
Merged
Merged
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
95cecbd
Pain
73da670
fix: Defer tracing decision for tracing context without performance
7b51e1b
Format
fb42a74
Merge branch 'lforst-defer-sampling-decision-when-tracing-without-per…
80b74da
.
109624c
lint
bd454b0
flush in lambdas and stuff
42d4b31
Add tests
287ac32
Assertion
1cd3b2a
.
f095cf6
.
bf14c8a
properly flush
a5c872e
Merge remote-tracking branch 'origin/develop' into lforst-app-router-…
12aa9f9
Fix test
cef9ee7
hm
7e9415d
Apply review feedback
ea87659
Don't capture errors for redirects
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
11 changes: 11 additions & 0 deletions
11
packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts
This file contains hidden or 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,11 @@ | ||
import { NextResponse } from 'next/server'; | ||
|
||
export const runtime = 'edge'; | ||
|
||
export async function PATCH() { | ||
return NextResponse.json({ name: 'John Doe' }, { status: 401 }); | ||
} | ||
|
||
export async function DELETE() { | ||
throw new Error('route-handler-edge-error'); | ||
} |
3 changes: 3 additions & 0 deletions
3
...ages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/error/route.ts
This file contains hidden or 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,3 @@ | ||
export async function PUT() { | ||
throw new Error('route-handler-error'); | ||
} |
9 changes: 9 additions & 0 deletions
9
packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts
This file contains hidden or 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,9 @@ | ||
import { NextResponse } from 'next/server'; | ||
|
||
export async function GET() { | ||
return NextResponse.json({ name: 'John Doe' }); | ||
} | ||
|
||
export async function POST() { | ||
return NextResponse.json({ name: 'John Doe' }, { status: 404 }); | ||
} |
This file contains hidden or 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
95 changes: 95 additions & 0 deletions
95
packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts
This file contains hidden or 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,95 @@ | ||
import { test, expect } from '@playwright/test'; | ||
import { waitForTransaction, waitForError } from '../event-proxy-server'; | ||
|
||
test('Should create a transaction for route handlers', async ({ request }) => { | ||
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { | ||
return transactionEvent?.transaction === 'GET /route-handlers/[param]'; | ||
}); | ||
|
||
const response = await request.get('/route-handlers/foo'); | ||
expect(await response.json()).toStrictEqual({ name: 'John Doe' }); | ||
|
||
const routehandlerTransaction = await routehandlerTransactionPromise; | ||
|
||
expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); | ||
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); | ||
}); | ||
|
||
test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ | ||
request, | ||
}) => { | ||
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { | ||
return transactionEvent?.transaction === 'POST /route-handlers/[param]'; | ||
}); | ||
|
||
const response = await request.post('/route-handlers/bar'); | ||
expect(await response.json()).toStrictEqual({ name: 'John Doe' }); | ||
|
||
const routehandlerTransaction = await routehandlerTransactionPromise; | ||
|
||
expect(routehandlerTransaction.contexts?.trace?.status).toBe('not_found'); | ||
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); | ||
}); | ||
|
||
test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => { | ||
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { | ||
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-error'; | ||
}); | ||
|
||
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { | ||
return transactionEvent?.transaction === 'PUT /route-handlers/[param]/error'; | ||
}); | ||
|
||
await request.put('/route-handlers/baz/error').catch(() => { | ||
// noop | ||
}); | ||
|
||
const routehandlerTransaction = await routehandlerTransactionPromise; | ||
const routehandlerError = await errorEventPromise; | ||
|
||
expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); | ||
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); | ||
|
||
expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error'); | ||
expect(routehandlerError.tags?.transaction).toBe('PUT /route-handlers/[param]/error'); | ||
}); | ||
|
||
test.describe('Edge runtime', () => { | ||
test('should create a transaction for route handlers', async ({ request }) => { | ||
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { | ||
return transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge'; | ||
}); | ||
|
||
const response = await request.patch('/route-handlers/bar/edge'); | ||
expect(await response.json()).toStrictEqual({ name: 'John Doe' }); | ||
|
||
const routehandlerTransaction = await routehandlerTransactionPromise; | ||
|
||
expect(routehandlerTransaction.contexts?.trace?.status).toBe('unauthenticated'); | ||
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); | ||
}); | ||
|
||
test('should record exceptions and transactions for faulty route handlers', async ({ request }) => { | ||
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { | ||
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error'; | ||
}); | ||
|
||
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { | ||
return transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge'; | ||
}); | ||
|
||
await request.delete('/route-handlers/baz/edge').catch(() => { | ||
// noop | ||
}); | ||
|
||
const routehandlerTransaction = await routehandlerTransactionPromise; | ||
const routehandlerError = await errorEventPromise; | ||
|
||
expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); | ||
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); | ||
expect(routehandlerTransaction.contexts?.runtime?.name).toBe('edge'); | ||
|
||
expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-edge-error'); | ||
expect(routehandlerError.contexts?.runtime?.name).toBe('edge'); | ||
}); | ||
}); |
This file contains hidden or 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 hidden or 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 hidden or 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 hidden or 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,71 @@ | ||
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; | ||
import { tracingContextFromHeaders } from '@sentry/utils'; | ||
|
||
import type { RouteHandlerContext } from './types'; | ||
import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; | ||
|
||
/** | ||
* Wraps a Next.js route handler with performance and error instrumentation. | ||
*/ | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>( | ||
routeHandler: F, | ||
context: RouteHandlerContext, | ||
): (...args: Parameters<F>) => ReturnType<F> extends Promise<unknown> ? ReturnType<F> : Promise<ReturnType<F>> { | ||
addTracingExtensions(); | ||
|
||
const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context; | ||
|
||
return new Proxy(routeHandler, { | ||
apply: (originalFunction, thisArg, args) => { | ||
return runWithAsyncContext(async () => { | ||
const hub = getCurrentHub(); | ||
const currentScope = hub.getScope(); | ||
|
||
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( | ||
sentryTraceHeader, | ||
baggageHeader, | ||
); | ||
currentScope.setPropagationContext(propagationContext); | ||
|
||
let res; | ||
try { | ||
res = await trace( | ||
{ | ||
op: 'http.server', | ||
name: `${method} ${parameterizedRoute}`, | ||
status: 'ok', | ||
...traceparentData, | ||
metadata: { | ||
source: 'route', | ||
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, | ||
}, | ||
}, | ||
async span => { | ||
const response: Response = await originalFunction.apply(thisArg, args); | ||
|
||
try { | ||
span?.setHttpStatus(response.status); | ||
} catch { | ||
// best effort | ||
} | ||
|
||
return response; | ||
}, | ||
error => { | ||
captureException(error); | ||
lforst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}, | ||
); | ||
} finally { | ||
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') { | ||
// 1. Edge tranpsort requires manual flushing | ||
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent | ||
await flush(1000); | ||
} | ||
} | ||
|
||
return res; | ||
}); | ||
}, | ||
}); | ||
} |
This file contains hidden or 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
75 changes: 75 additions & 0 deletions
75
packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts
This file contains hidden or 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,75 @@ | ||
// @ts-ignore Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public | ||
// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader. | ||
// eslint-disable-next-line import/no-unresolved | ||
import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__'; | ||
// @ts-ignore See above | ||
// eslint-disable-next-line import/no-unresolved | ||
import * as routeModule from '__SENTRY_WRAPPING_TARGET_FILE__'; | ||
// eslint-disable-next-line import/no-extraneous-dependencies | ||
import * as Sentry from '@sentry/nextjs'; | ||
|
||
import type { RequestAsyncStorage } from './requestAsyncStorageShim'; | ||
|
||
declare const requestAsyncStorage: RequestAsyncStorage; | ||
|
||
declare const routeModule: { | ||
default: unknown; | ||
GET?: (...args: unknown[]) => unknown; | ||
POST?: (...args: unknown[]) => unknown; | ||
PUT?: (...args: unknown[]) => unknown; | ||
PATCH?: (...args: unknown[]) => unknown; | ||
DELETE?: (...args: unknown[]) => unknown; | ||
HEAD?: (...args: unknown[]) => unknown; | ||
OPTIONS?: (...args: unknown[]) => unknown; | ||
}; | ||
|
||
function wrapHandler<T>(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'): T { | ||
// Running the instrumentation code during the build phase will mark any function as "dynamic" because we're accessing | ||
// the Request object. We do not want to turn handlers dynamic so we skip instrumentation in the build phase. | ||
if (process.env.NEXT_PHASE === 'phase-production-build') { | ||
return handler; | ||
} | ||
|
||
if (typeof handler !== 'function') { | ||
return handler; | ||
} | ||
|
||
return new Proxy(handler, { | ||
apply: (originalFunction, thisArg, args) => { | ||
let sentryTraceHeader: string | undefined | null = undefined; | ||
let baggageHeader: string | undefined | null = undefined; | ||
|
||
// We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API | ||
try { | ||
const requestAsyncStore = requestAsyncStorage.getStore(); | ||
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; | ||
baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; | ||
} catch (e) { | ||
/** empty */ | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | ||
return Sentry.wrapRouteHandlerWithSentry(originalFunction as any, { | ||
method, | ||
parameterizedRoute: '__ROUTE__', | ||
sentryTraceHeader, | ||
baggageHeader, | ||
}).apply(thisArg, args); | ||
}, | ||
}); | ||
} | ||
|
||
export const GET = wrapHandler(routeModule.GET, 'GET'); | ||
export const POST = wrapHandler(routeModule.POST, 'POST'); | ||
export const PUT = wrapHandler(routeModule.PUT, 'PUT'); | ||
export const PATCH = wrapHandler(routeModule.PATCH, 'PATCH'); | ||
export const DELETE = wrapHandler(routeModule.DELETE, 'DELETE'); | ||
export const HEAD = wrapHandler(routeModule.HEAD, 'HEAD'); | ||
export const OPTIONS = wrapHandler(routeModule.OPTIONS, 'OPTIONS'); | ||
|
||
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to | ||
// not include anything whose name matchs something we've explicitly exported above. | ||
// @ts-ignore See above | ||
// eslint-disable-next-line import/no-unresolved | ||
export * from '__SENTRY_WRAPPING_TARGET_FILE__'; | ||
export default routeModule.default; |
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.
Uh oh!
There was an error while loading. Please reload this page.