Skip to content

Commit dcc7680

Browse files
authored
feat(nextjs): Add edge route and middleware wrappers (#6771)
1 parent 56d3a74 commit dcc7680

File tree

9 files changed

+383
-17
lines changed

9 files changed

+383
-17
lines changed

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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: { spanDescription: 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.spanDescription,
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.spanDescription,
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+
span?.setStatus('internal_error');
81+
82+
captureException(objectifiedErr, scope => {
83+
scope.addEventProcessor(event => {
84+
addExceptionMechanism(event, {
85+
type: 'instrument',
86+
handled: false,
87+
data: {
88+
function: options.mechanismFunctionName,
89+
},
90+
});
91+
return event;
92+
});
93+
94+
return scope;
95+
});
96+
97+
throw objectifiedErr;
98+
} finally {
99+
span?.finish();
100+
currentScope?.setSpan(prevSpan);
101+
await flush(2000);
102+
}
103+
};
104+
}
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 activeSpan = !!getCurrentHub().getScope()?.getSpan();
17+
18+
const wrappedHandler = withEdgeWrapping(handler, {
19+
spanDescription:
20+
activeSpan || !(req instanceof Request)
21+
? `handler (${parameterizedRoute})`
22+
: `${req.method} ${parameterizedRoute}`,
23+
spanOp: activeSpan ? '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+
spanDescription: '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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
// @ts-ignore Request does not exist on type Global
7+
const origRequest = global.Request;
8+
// @ts-ignore Response does not exist on type Global
9+
const origResponse = global.Response;
10+
11+
// @ts-ignore Request does not exist on type Global
12+
global.Request = class Request {
13+
headers = {
14+
get() {
15+
return null;
16+
},
17+
};
18+
};
19+
20+
// @ts-ignore Response does not exist on type Global
21+
global.Response = class Request {};
22+
23+
afterAll(() => {
24+
// @ts-ignore Request does not exist on type Global
25+
global.Request = origRequest;
26+
// @ts-ignore Response does not exist on type Global
27+
global.Response = origResponse;
28+
});
29+
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
jest.resetAllMocks();
33+
jest.spyOn(sentryTracing, 'hasTracingEnabled').mockImplementation(() => true);
34+
});
35+
36+
describe('withEdgeWrapping', () => {
37+
it('should return a function that calls the passed function', async () => {
38+
const origFunctionReturnValue = new Response();
39+
const origFunction = jest.fn(_req => origFunctionReturnValue);
40+
41+
const wrappedFunction = withEdgeWrapping(origFunction, {
42+
spanDescription: 'some label',
43+
mechanismFunctionName: 'some name',
44+
spanOp: 'some op',
45+
});
46+
47+
const returnValue = await wrappedFunction(new Request('https://sentry.io/'));
48+
49+
expect(returnValue).toBe(origFunctionReturnValue);
50+
expect(origFunction).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it('should return a function that calls captureException on error', async () => {
54+
const captureExceptionSpy = jest.spyOn(coreSdk, 'captureException');
55+
const error = new Error();
56+
const origFunction = jest.fn(_req => {
57+
throw error;
58+
});
59+
60+
const wrappedFunction = withEdgeWrapping(origFunction, {
61+
spanDescription: 'some label',
62+
mechanismFunctionName: 'some name',
63+
spanOp: 'some op',
64+
});
65+
66+
await expect(wrappedFunction(new Request('https://sentry.io/'))).rejects.toBe(error);
67+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it('should return a function that starts a transaction when a request object is passed', async () => {
71+
const startTransactionSpy = jest.spyOn(coreSdk, 'startTransaction');
72+
73+
const origFunctionReturnValue = new Response();
74+
const origFunction = jest.fn(_req => origFunctionReturnValue);
75+
76+
const wrappedFunction = withEdgeWrapping(origFunction, {
77+
spanDescription: 'some label',
78+
mechanismFunctionName: 'some name',
79+
spanOp: 'some op',
80+
});
81+
82+
const request = new Request('https://sentry.io/');
83+
await wrappedFunction(request);
84+
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
85+
expect(startTransactionSpy).toHaveBeenCalledWith(
86+
expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }),
87+
{ request },
88+
);
89+
});
90+
91+
it("should return a function that doesn't crash when req isn't passed", async () => {
92+
const origFunctionReturnValue = new Response();
93+
const origFunction = jest.fn(() => origFunctionReturnValue);
94+
95+
const wrappedFunction = withEdgeWrapping(origFunction, {
96+
spanDescription: 'some label',
97+
mechanismFunctionName: 'some name',
98+
spanOp: 'some op',
99+
});
100+
101+
await expect(wrappedFunction()).resolves.toBe(origFunctionReturnValue);
102+
expect(origFunction).toHaveBeenCalledTimes(1);
103+
});
104+
});

0 commit comments

Comments
 (0)