Skip to content

Commit

Permalink
feat(nextjs): Add edge route and middleware wrappers (#6771)
Browse files Browse the repository at this point in the history
  • Loading branch information
lforst committed Jan 16, 2023
1 parent 56d3a74 commit dcc7680
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 17 deletions.
21 changes: 4 additions & 17 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,6 @@ export async function close(timeout?: number): Promise<boolean> {
return Promise.resolve(false);
}

/**
* 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<EdgeClient>();
if (client) {
return client.flush(timeout);
}
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.');
return Promise.resolve(false);
}

/**
* This is the getter for lastEventId.
*
Expand All @@ -145,4 +128,8 @@ export function lastEventId(): string | undefined {
return getCurrentHub().lastEventId();
}

export { flush } from './utils/flush';

export * from '@sentry/core';
export { withSentryAPI } from './withSentryAPI';
export { withSentryMiddleware } from './withSentryMiddleware';
5 changes: 5 additions & 0 deletions packages/nextjs/src/edge/types.ts
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>;
}
104 changes: 104 additions & 0 deletions packages/nextjs/src/edge/utils/edgeWrapperUtils.ts
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);
}
};
}
20 changes: 20 additions & 0 deletions packages/nextjs/src/edge/utils/flush.ts
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);
}
29 changes: 29 additions & 0 deletions packages/nextjs/src/edge/withSentryAPI.ts
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);
};
}
15 changes: 15 additions & 0 deletions packages/nextjs/src/edge/withSentryMiddleware.ts
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',
});
}
7 changes: 7 additions & 0 deletions packages/nextjs/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ export declare function close(timeout?: number | undefined): PromiseLike<boolean
export declare function flush(timeout?: number | undefined): PromiseLike<boolean>;
export declare function lastEventId(): string | undefined;
export declare function getSentryRelease(fallback?: string): string | undefined;

export declare function withSentryAPI<APIHandler extends (...args: any[]) => any>(
handler: APIHandler,
parameterizedRoute: string,
): (
...args: Parameters<APIHandler>
) => ReturnType<APIHandler> extends Promise<unknown> ? ReturnType<APIHandler> : Promise<ReturnType<APIHandler>>;
104 changes: 104 additions & 0 deletions packages/nextjs/test/edge/edgeWrapperUtils.test.ts
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);
});
});
Loading

0 comments on commit dcc7680

Please sign in to comment.