Skip to content

feat(nextjs): Send events consistently on platforms that don't support streaming #6578

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 4 commits into from
Jan 3, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/nextjs/src/config/wrappers/utils/responseEnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@ import { ResponseEndMethod, WrappedResponseEndMethod } from '../types';
*/
export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void {
const wrapEndMethod = (origEnd: ResponseEndMethod): WrappedResponseEndMethod => {
return async function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) {
await finishTransaction(transaction, this);
await flushQueue();

return function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) {
void finishTransaction(transaction, this);
return origEnd.call(this, ...args);
};
};
Expand Down
51 changes: 47 additions & 4 deletions packages/nextjs/src/config/wrappers/withSentryAPI.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { captureException, getCurrentHub, startTransaction } from '@sentry/node';
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
import { Transaction } from '@sentry/types';
import {
addExceptionMechanism,
baggageHeaderToDynamicSamplingContext,
Expand All @@ -11,6 +12,7 @@ import {
import * as domain from 'domain';

import { formatAsCode, nextLogger } from '../../utils/nextLogger';
import { platformSupportsStreaming } from '../../utils/platformSupportsStreaming';
import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler, WrappedNextApiHandler } from './types';
import { autoEndTransactionOnResponseEnd, finishTransaction, flushQueue } from './utils/responseEnd';

Expand Down Expand Up @@ -74,8 +76,9 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
// `local.bind` causes everything to run inside a domain, just like `local.run` does, but it also lets the callback
// return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
// getting that before it will finish the response.
// eslint-disable-next-line complexity
const boundHandler = local.bind(async () => {
let transaction;
let transaction: Transaction | undefined;
const currentScope = getCurrentHub().getScope();

if (currentScope) {
Expand Down Expand Up @@ -127,7 +130,43 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
);
currentScope.setSpan(transaction);

autoEndTransactionOnResponseEnd(transaction, res);
if (platformSupportsStreaming()) {
autoEndTransactionOnResponseEnd(transaction, res);
} else {
// If we're not on a platform that supports streaming, we're blocking all response-ending methods until the
// queue is flushed.

const origResSend = res.send;
res.send = async function (this: unknown, ...args: unknown[]) {
if (transaction) {
await finishTransaction(transaction, res);
await flushQueue();
}

origResSend.apply(this, args);
};

const origResJson = res.json;
res.json = async function (this: unknown, ...args: unknown[]) {
if (transaction) {
await finishTransaction(transaction, res);
await flushQueue();
}

origResJson.apply(this, args);
};

// eslint-disable-next-line @typescript-eslint/unbound-method
const origResEnd = res.end;
res.end = async function (this: unknown, ...args: unknown[]) {
if (transaction) {
await finishTransaction(transaction, res);
await flushQueue();
}

origResEnd.apply(this, args);
};
}
}
}

Expand Down Expand Up @@ -184,8 +223,12 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
// moment they detect an error, so it's important to get this done before rethrowing the error. Apps not
// deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already
// be finished and the queue will already be empty, so effectively it'll just no-op.)
await finishTransaction(transaction, res);
await flushQueue();
if (platformSupportsStreaming()) {
void finishTransaction(transaction, res);
} else {
await finishTransaction(transaction, res);
await flushQueue();
}

// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
Expand Down
98 changes: 58 additions & 40 deletions packages/nextjs/src/config/wrappers/wrapperUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { baggageHeaderToDynamicSamplingContext, extractTraceparentData } from '@
import * as domain from 'domain';
import { IncomingMessage, ServerResponse } from 'http';

import { autoEndTransactionOnResponseEnd } from './utils/responseEnd';
import { platformSupportsStreaming } from '../../utils/platformSupportsStreaming';
import { autoEndTransactionOnResponseEnd, flushQueue } from './utils/responseEnd';

declare module 'http' {
interface IncomingMessage {
Expand Down Expand Up @@ -77,43 +78,62 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
return async function (this: unknown, ...args: Parameters<F>): Promise<ReturnType<F>> {
return domain.create().bind(async () => {
let requestTransaction: Transaction | undefined = getTransactionFromRequest(req);

if (requestTransaction === undefined) {
const sentryTraceHeader = req.headers['sentry-trace'];
const rawBaggageString = req.headers && req.headers.baggage;
const traceparentData =
typeof sentryTraceHeader === 'string' ? extractTraceparentData(sentryTraceHeader) : undefined;

const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(rawBaggageString);

const newTransaction = startTransaction(
{
op: 'http.server',
name: options.requestedRouteName,
...traceparentData,
status: 'ok',
metadata: {
source: 'route',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
let dataFetcherSpan;

const sentryTraceHeader = req.headers['sentry-trace'];
const rawBaggageString = req.headers && req.headers.baggage;
const traceparentData =
typeof sentryTraceHeader === 'string' ? extractTraceparentData(sentryTraceHeader) : undefined;

const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(rawBaggageString);

if (platformSupportsStreaming()) {
if (requestTransaction === undefined) {
const newTransaction = startTransaction(
{
op: 'http.server',
name: options.requestedRouteName,
...traceparentData,
status: 'ok',
metadata: {
request: req,
source: 'route',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
},
},
{ request: req },
);
{ request: req },
);

requestTransaction = newTransaction;
autoEndTransactionOnResponseEnd(newTransaction, res);
requestTransaction = newTransaction;

// Link the transaction and the request together, so that when we would normally only have access to one, it's
// still possible to grab the other.
setTransactionOnRequest(newTransaction, req);
newTransaction.setMetadata({ request: req });
}
if (platformSupportsStreaming()) {
// On platforms that don't support streaming, doing things after res.end() is unreliable.
autoEndTransactionOnResponseEnd(newTransaction, res);
}

const dataFetcherSpan = requestTransaction.startChild({
op: 'function.nextjs',
description: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
status: 'ok',
});
// Link the transaction and the request together, so that when we would normally only have access to one, it's
// still possible to grab the other.
setTransactionOnRequest(newTransaction, req);
}

dataFetcherSpan = requestTransaction.startChild({
op: 'function.nextjs',
description: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
status: 'ok',
});
} else {
dataFetcherSpan = startTransaction({
op: 'function.nextjs',
name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
...traceparentData,
status: 'ok',
metadata: {
request: req,
source: 'route',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
});
}

const currentScope = getCurrentHub().getScope();
if (currentScope) {
Expand All @@ -127,15 +147,13 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
// Since we finish the span before the error can bubble up and trigger the handlers in `registerErrorInstrumentation`
// that set the transaction status, we need to manually set the status of the span & transaction
dataFetcherSpan.setStatus('internal_error');

const transaction = dataFetcherSpan.transaction;
if (transaction) {
transaction.setStatus('internal_error');
}

requestTransaction?.setStatus('internal_error');
throw e;
} finally {
dataFetcherSpan.finish();
if (!platformSupportsStreaming()) {
await flushQueue();
}
}
})();
};
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/utils/platformSupportsStreaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const platformSupportsStreaming = (): boolean => !process.env.LAMBDA_TASK_ROOT && !process.env.VERCEL;
45 changes: 0 additions & 45 deletions packages/nextjs/test/config/withSentry.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as hub from '@sentry/core';
import * as Sentry from '@sentry/node';
import { Client, ClientOptions } from '@sentry/types';
import * as utils from '@sentry/utils';
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';

import { AugmentedNextApiResponse, withSentry, WrappedNextApiHandler } from '../../src/config/wrappers';
Expand Down Expand Up @@ -36,31 +35,16 @@ async function callWrappedHandler(wrappedHandler: WrappedNextApiHandler, req: Ne
}
}

// We mock `captureException` as a no-op because under normal circumstances it is an un-awaited effectively-async
// function which might or might not finish before any given test ends, potentially leading jest to error out.
const captureExceptionSpy = jest.spyOn(Sentry, 'captureException').mockImplementation(jest.fn());
const loggerSpy = jest.spyOn(utils.logger, 'log');
const flushSpy = jest.spyOn(Sentry, 'flush').mockImplementation(async () => {
// simulate the time it takes time to flush all events
await sleep(FLUSH_DURATION);
return true;
});
const startTransactionSpy = jest.spyOn(Sentry, 'startTransaction');

describe('withSentry', () => {
let req: NextApiRequest, res: NextApiResponse;

const noShoesError = new Error('Oh, no! Charlie ate the flip-flops! :-(');

const origHandlerNoError: NextApiHandler = async (_req, res) => {
res.send('Good dog, Maisey!');
};
const origHandlerWithError: NextApiHandler = async (_req, _res) => {
throw noShoesError;
};

const wrappedHandlerNoError = withSentry(origHandlerNoError);
const wrappedHandlerWithError = withSentry(origHandlerWithError);

beforeEach(() => {
req = { url: 'http://dogs.are.great' } as NextApiRequest;
Expand All @@ -78,35 +62,6 @@ describe('withSentry', () => {
jest.clearAllMocks();
});

describe('flushing', () => {
it('flushes events before rethrowing error', async () => {
try {
await callWrappedHandler(wrappedHandlerWithError, req, res);
} catch (err) {
expect(err).toBe(noShoesError);
}

expect(captureExceptionSpy).toHaveBeenCalledWith(noShoesError);
expect(flushSpy).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith('Done flushing events');

// This ensures the expect inside the `catch` block actually ran, i.e., that in the end the wrapped handler
// errored out the same way it would without sentry, meaning the error was indeed rethrown
expect.assertions(4);
});

it('flushes events before finishing non-erroring response', async () => {
jest
.spyOn(hub.Hub.prototype, 'getClient')
.mockReturnValueOnce({ getOptions: () => ({ tracesSampleRate: 1 } as ClientOptions) } as Client);

await callWrappedHandler(wrappedHandlerNoError, req, res);

expect(flushSpy).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith('Done flushing events');
});
});

describe('tracing', () => {
it('starts a transaction and sets metadata when tracing is enabled', async () => {
jest
Expand Down
9 changes: 2 additions & 7 deletions packages/nextjs/test/config/wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
} from '../../src/config/wrappers';

const startTransactionSpy = jest.spyOn(SentryCore, 'startTransaction');
const setMetadataSpy = jest.spyOn(SentryTracing.Transaction.prototype, 'setMetadata');

describe('data-fetching function wrappers', () => {
const route = '/tricks/[trickName]';
Expand Down Expand Up @@ -43,16 +42,14 @@ describe('data-fetching function wrappers', () => {
expect.objectContaining({
name: '/tricks/[trickName]',
op: 'http.server',
metadata: expect.objectContaining({ source: 'route' }),
metadata: expect.objectContaining({ source: 'route', request: req }),
}),
{
request: expect.objectContaining({
url: 'http://dogs.are.great/tricks/kangaroo',
}),
},
);

expect(setMetadataSpy).toHaveBeenCalledWith({ request: req });
});

test('withSentryServerSideGetInitialProps', async () => {
Expand All @@ -65,16 +62,14 @@ describe('data-fetching function wrappers', () => {
expect.objectContaining({
name: '/tricks/[trickName]',
op: 'http.server',
metadata: expect.objectContaining({ source: 'route' }),
metadata: expect.objectContaining({ source: 'route', request: req }),
}),
{
request: expect.objectContaining({
url: 'http://dogs.are.great/tricks/kangaroo',
}),
},
);

expect(setMetadataSpy).toHaveBeenCalledWith({ request: req });
});
});
});