Skip to content

Commit 6f4b028

Browse files
author
Luca Forstner
committed
feat(nextjs): Add edge route and middleware wrappers
1 parent f1afb1f commit 6f4b028

File tree

12 files changed

+263
-19
lines changed

12 files changed

+263
-19
lines changed

packages/nextjs/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ module.exports = {
55
// This prevents the build tests from running when unit tests run. (If they do, they fail, because the build being
66
// tested hasn't necessarily run yet.)
77
testPathIgnorePatterns: ['<rootDir>/test/buildProcess/'],
8+
setupFiles: ['<rootDir>/test/setupUnitTests.ts'],
89
};

packages/nextjs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"devDependencies": {
3333
"@types/webpack": "^4.41.31",
3434
"eslint-plugin-react": "^7.31.11",
35-
"next": "10.1.3"
35+
"next": "10.1.3",
36+
"whatwg-fetch": "3.6.2"
3637
},
3738
"peerDependencies": {
3839
"next": "^10.0.8 || ^11.0 || ^12.0 || ^13.0",

packages/nextjs/src/edge/index.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,23 +119,6 @@ export async function close(timeout?: number): Promise<boolean> {
119119
return Promise.resolve(false);
120120
}
121121

122-
/**
123-
* Call `flush()` on the current client, if there is one. See {@link Client.flush}.
124-
*
125-
* @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause
126-
* the client to wait until all events are sent before resolving the promise.
127-
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
128-
* doesn't (or if there's no client defined).
129-
*/
130-
export async function flush(timeout?: number): Promise<boolean> {
131-
const client = getCurrentHub().getClient<EdgeClient>();
132-
if (client) {
133-
return client.flush(timeout);
134-
}
135-
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.');
136-
return Promise.resolve(false);
137-
}
138-
139122
/**
140123
* This is the getter for lastEventId.
141124
*
@@ -145,4 +128,8 @@ export function lastEventId(): string | undefined {
145128
return getCurrentHub().lastEventId();
146129
}
147130

131+
export { flush } from './utils/flush';
132+
148133
export * from '@sentry/core';
134+
export { withSentryAPI } from './withSentryAPI';
135+
export { withSentryMiddleware } from './withSentryMiddleware';

packages/nextjs/src/edge/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// We cannot make any assumptions about what users define as their handler except maybe that it is a function
2+
export interface EdgeRouteHandler {
3+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4+
(req: any): any | Promise<any>;
5+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { captureException, getCurrentHub, startTransaction } from '@sentry/core';
2+
import { hasTracingEnabled } from '@sentry/tracing';
3+
import type { Span } from '@sentry/types';
4+
import {
5+
addExceptionMechanism,
6+
baggageHeaderToDynamicSamplingContext,
7+
extractTraceparentData,
8+
logger,
9+
objectify,
10+
} from '@sentry/utils';
11+
12+
import type { EdgeRouteHandler } from '../types';
13+
import { flush } from './flush';
14+
15+
/**
16+
* Wraps a function on the edge runtime with error and performance monitoring.
17+
*/
18+
export function withEdgeWrapping<H extends EdgeRouteHandler>(
19+
handler: H,
20+
options: { spanLabel: string; spanOp: string; mechanismFunctionName: string },
21+
): (...params: Parameters<H>) => Promise<ReturnType<H>> {
22+
return async function (this: unknown, ...args) {
23+
const req = args[0];
24+
const currentScope = getCurrentHub().getScope();
25+
const prevSpan = currentScope?.getSpan();
26+
27+
let span: Span | undefined;
28+
29+
if (hasTracingEnabled()) {
30+
if (prevSpan) {
31+
span = prevSpan.startChild({
32+
description: options.spanLabel,
33+
op: options.spanOp,
34+
});
35+
} else if (req instanceof Request) {
36+
// If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
37+
let traceparentData;
38+
39+
const sentryTraceHeader = req.headers.get('sentry-trace');
40+
if (sentryTraceHeader) {
41+
traceparentData = extractTraceparentData(sentryTraceHeader);
42+
__DEBUG_BUILD__ && logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`);
43+
}
44+
45+
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(req.headers.get('baggage'));
46+
47+
span = startTransaction(
48+
{
49+
name: options.spanLabel,
50+
op: options.spanOp,
51+
...traceparentData,
52+
metadata: {
53+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
54+
source: 'route',
55+
},
56+
},
57+
// extra context passed to the `tracesSampler`
58+
{ request: req },
59+
);
60+
}
61+
62+
currentScope?.setSpan(span);
63+
}
64+
65+
try {
66+
const handlerResult: ReturnType<H> = await handler.apply(this, args);
67+
68+
if ((handlerResult as unknown) instanceof Response) {
69+
span?.setHttpStatus(handlerResult.status);
70+
} else {
71+
span?.setStatus('ok');
72+
}
73+
74+
return handlerResult;
75+
} catch (e) {
76+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
77+
// store a seen flag on it.
78+
const objectifiedErr = objectify(e);
79+
80+
currentScope?.addEventProcessor(event => {
81+
addExceptionMechanism(event, {
82+
type: 'instrument',
83+
handled: false,
84+
data: {
85+
function: options.mechanismFunctionName,
86+
},
87+
});
88+
return event;
89+
});
90+
91+
span?.setStatus('internal_error');
92+
93+
captureException(objectifiedErr);
94+
95+
throw objectifiedErr;
96+
} finally {
97+
span?.finish();
98+
currentScope?.setSpan(prevSpan);
99+
await flush(2000);
100+
}
101+
};
102+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getCurrentHub } from '@sentry/core';
2+
import type { Client } from '@sentry/types';
3+
import { logger } from '@sentry/utils';
4+
5+
/**
6+
* Call `flush()` on the current client, if there is one. See {@link Client.flush}.
7+
*
8+
* @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause
9+
* the client to wait until all events are sent before resolving the promise.
10+
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
11+
* doesn't (or if there's no client defined).
12+
*/
13+
export async function flush(timeout?: number): Promise<boolean> {
14+
const client = getCurrentHub().getClient<Client>();
15+
if (client) {
16+
return client.flush(timeout);
17+
}
18+
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.');
19+
return Promise.resolve(false);
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { getCurrentHub } from '@sentry/core';
2+
3+
import type { EdgeRouteHandler } from './types';
4+
import { withEdgeWrapping } from './utils/edgeWrapperUtils';
5+
6+
/**
7+
* Wraps a Next.js edge route handler with Sentry error and performance instrumentation.
8+
*/
9+
export function withSentryAPI<H extends EdgeRouteHandler>(
10+
handler: H,
11+
parameterizedRoute: string,
12+
): (...params: Parameters<H>) => Promise<ReturnType<H>> {
13+
return async function (this: unknown, ...args: Parameters<H>): Promise<ReturnType<H>> {
14+
const req = args[0];
15+
16+
const isCalledByUser = getCurrentHub().getScope()?.getTransaction();
17+
18+
const wrappedHandler = withEdgeWrapping(handler, {
19+
spanLabel:
20+
isCalledByUser || !(req instanceof Request)
21+
? `handler (${parameterizedRoute})`
22+
: `${req.method} ${parameterizedRoute}`,
23+
spanOp: isCalledByUser ? 'function' : 'http.server',
24+
mechanismFunctionName: 'withSentryAPI',
25+
});
26+
27+
return await wrappedHandler.apply(this, args);
28+
};
29+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { EdgeRouteHandler } from './types';
2+
import { withEdgeWrapping } from './utils/edgeWrapperUtils';
3+
4+
/**
5+
* Wraps Next.js middleware with Sentry error and performance instrumentation.
6+
*/
7+
export function withSentryMiddleware<H extends EdgeRouteHandler>(
8+
middleware: H,
9+
): (...params: Parameters<H>) => Promise<ReturnType<H>> {
10+
return withEdgeWrapping(middleware, {
11+
spanLabel: 'middleware',
12+
spanOp: 'middleware.nextjs',
13+
mechanismFunctionName: 'withSentryMiddleware',
14+
});
15+
}

packages/nextjs/src/index.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,10 @@ export declare function close(timeout?: number | undefined): PromiseLike<boolean
2929
export declare function flush(timeout?: number | undefined): PromiseLike<boolean>;
3030
export declare function lastEventId(): string | undefined;
3131
export declare function getSentryRelease(fallback?: string): string | undefined;
32+
33+
export declare function withSentryAPI<APIHandler extends (...args: any[]) => any>(
34+
handler: APIHandler,
35+
parameterizedRoute: string,
36+
): (
37+
...args: Parameters<APIHandler>
38+
) => ReturnType<APIHandler> extends Promise<unknown> ? ReturnType<APIHandler> : Promise<ReturnType<APIHandler>>;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as coreSdk from '@sentry/core';
2+
import * as sentryTracing from '@sentry/tracing';
3+
4+
import { withEdgeWrapping } from '../../src/edge/utils/edgeWrapperUtils';
5+
6+
jest.spyOn(sentryTracing, 'hasTracingEnabled').mockImplementation(() => true);
7+
8+
describe('withEdgeWrapping', () => {
9+
it('should return a function that calls the passed function', async () => {
10+
const origFunctionReturnValue = new Response();
11+
const origFunction = jest.fn(_req => origFunctionReturnValue);
12+
13+
const wrappedFunction = withEdgeWrapping(origFunction, {
14+
spanLabel: 'some label',
15+
mechanismFunctionName: 'some name',
16+
spanOp: 'some op',
17+
});
18+
19+
const returnValue = await wrappedFunction(new Request('https://sentry.io/'));
20+
21+
expect(returnValue).toBe(origFunctionReturnValue);
22+
expect(origFunction).toHaveBeenCalledTimes(1);
23+
});
24+
25+
it('should return a function that calls captureException on error', async () => {
26+
const captureExceptionSpy = jest.spyOn(coreSdk, 'captureException');
27+
const error = new Error();
28+
const origFunction = jest.fn(_req => {
29+
throw error;
30+
});
31+
32+
const wrappedFunction = withEdgeWrapping(origFunction, {
33+
spanLabel: 'some label',
34+
mechanismFunctionName: 'some name',
35+
spanOp: 'some op',
36+
});
37+
38+
await expect(wrappedFunction(new Request('https://sentry.io/'))).rejects.toBe(error);
39+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('should return a function that starts a transaction when a request object is passed', async () => {
43+
const startTransactionSpy = jest.spyOn(coreSdk, 'startTransaction');
44+
45+
const origFunctionReturnValue = new Response();
46+
const origFunction = jest.fn(_req => origFunctionReturnValue);
47+
48+
const wrappedFunction = withEdgeWrapping(origFunction, {
49+
spanLabel: 'some label',
50+
mechanismFunctionName: 'some name',
51+
spanOp: 'some op',
52+
});
53+
54+
const request = new Request('https://sentry.io/');
55+
await wrappedFunction(request);
56+
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
57+
expect(startTransactionSpy).toHaveBeenCalledWith(
58+
expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }),
59+
{ request },
60+
);
61+
});
62+
63+
it("should return a function that doesn't crash when req isn't passed", async () => {
64+
const origFunctionReturnValue = new Response();
65+
const origFunction = jest.fn(() => origFunctionReturnValue);
66+
67+
const wrappedFunction = withEdgeWrapping(origFunction, {
68+
spanLabel: 'some label',
69+
mechanismFunctionName: 'some name',
70+
spanOp: 'some op',
71+
});
72+
73+
await expect(wrappedFunction()).resolves.toBe(origFunctionReturnValue);
74+
expect(origFunction).toHaveBeenCalledTimes(1);
75+
});
76+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import 'whatwg-fetch'; // polyfill fetch/Request/Response globals which edge routes need

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25053,7 +25053,7 @@ whatwg-encoding@^2.0.0:
2505325053
dependencies:
2505425054
iconv-lite "0.6.3"
2505525055

25056-
whatwg-fetch@>=0.10.0:
25056+
whatwg-fetch@3.6.2, whatwg-fetch@>=0.10.0:
2505725057
version "3.6.2"
2505825058
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
2505925059
integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==

0 commit comments

Comments
 (0)