Skip to content
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 6 commits into from
Jan 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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>;
Copy link
Member

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?

Copy link
Member Author

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.

}
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>(
Copy link
Member

Choose a reason for hiding this comment

The 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 spanLabel and spanOp

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
};
}
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);
});
});