Skip to content

Commit 371d42d

Browse files
authored
feat(nextjs): Send events consistently on platforms that don't support streaming (#6578)
1 parent 3b1bcaf commit 371d42d

File tree

6 files changed

+110
-100
lines changed

6 files changed

+110
-100
lines changed

packages/nextjs/src/config/wrappers/utils/responseEnd.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,8 @@ import { ResponseEndMethod, WrappedResponseEndMethod } from '../types';
2424
*/
2525
export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void {
2626
const wrapEndMethod = (origEnd: ResponseEndMethod): WrappedResponseEndMethod => {
27-
return async function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) {
28-
await finishTransaction(transaction, this);
29-
await flushQueue();
30-
27+
return function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) {
28+
void finishTransaction(transaction, this);
3129
return origEnd.call(this, ...args);
3230
};
3331
};

packages/nextjs/src/config/wrappers/withSentryAPI.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { captureException, getCurrentHub, startTransaction } from '@sentry/node';
22
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
3+
import { Transaction } from '@sentry/types';
34
import {
45
addExceptionMechanism,
56
baggageHeaderToDynamicSamplingContext,
@@ -11,6 +12,7 @@ import {
1112
import * as domain from 'domain';
1213

1314
import { formatAsCode, nextLogger } from '../../utils/nextLogger';
15+
import { platformSupportsStreaming } from '../../utils/platformSupportsStreaming';
1416
import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler, WrappedNextApiHandler } from './types';
1517
import { autoEndTransactionOnResponseEnd, finishTransaction, flushQueue } from './utils/responseEnd';
1618

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

8184
if (currentScope) {
@@ -127,7 +130,43 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
127130
);
128131
currentScope.setSpan(transaction);
129132

130-
autoEndTransactionOnResponseEnd(transaction, res);
133+
if (platformSupportsStreaming()) {
134+
autoEndTransactionOnResponseEnd(transaction, res);
135+
} else {
136+
// If we're not on a platform that supports streaming, we're blocking all response-ending methods until the
137+
// queue is flushed.
138+
139+
const origResSend = res.send;
140+
res.send = async function (this: unknown, ...args: unknown[]) {
141+
if (transaction) {
142+
await finishTransaction(transaction, res);
143+
await flushQueue();
144+
}
145+
146+
origResSend.apply(this, args);
147+
};
148+
149+
const origResJson = res.json;
150+
res.json = async function (this: unknown, ...args: unknown[]) {
151+
if (transaction) {
152+
await finishTransaction(transaction, res);
153+
await flushQueue();
154+
}
155+
156+
origResJson.apply(this, args);
157+
};
158+
159+
// eslint-disable-next-line @typescript-eslint/unbound-method
160+
const origResEnd = res.end;
161+
res.end = async function (this: unknown, ...args: unknown[]) {
162+
if (transaction) {
163+
await finishTransaction(transaction, res);
164+
await flushQueue();
165+
}
166+
167+
origResEnd.apply(this, args);
168+
};
169+
}
131170
}
132171
}
133172

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

190233
// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
191234
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark

packages/nextjs/src/config/wrappers/wrapperUtils.ts

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { baggageHeaderToDynamicSamplingContext, extractTraceparentData } from '@
55
import * as domain from 'domain';
66
import { IncomingMessage, ServerResponse } from 'http';
77

8-
import { autoEndTransactionOnResponseEnd } from './utils/responseEnd';
8+
import { platformSupportsStreaming } from '../../utils/platformSupportsStreaming';
9+
import { autoEndTransactionOnResponseEnd, flushQueue } from './utils/responseEnd';
910

1011
declare module 'http' {
1112
interface IncomingMessage {
@@ -77,43 +78,62 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
7778
return async function (this: unknown, ...args: Parameters<F>): Promise<ReturnType<F>> {
7879
return domain.create().bind(async () => {
7980
let requestTransaction: Transaction | undefined = getTransactionFromRequest(req);
80-
81-
if (requestTransaction === undefined) {
82-
const sentryTraceHeader = req.headers['sentry-trace'];
83-
const rawBaggageString = req.headers && req.headers.baggage;
84-
const traceparentData =
85-
typeof sentryTraceHeader === 'string' ? extractTraceparentData(sentryTraceHeader) : undefined;
86-
87-
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(rawBaggageString);
88-
89-
const newTransaction = startTransaction(
90-
{
91-
op: 'http.server',
92-
name: options.requestedRouteName,
93-
...traceparentData,
94-
status: 'ok',
95-
metadata: {
96-
source: 'route',
97-
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
81+
let dataFetcherSpan;
82+
83+
const sentryTraceHeader = req.headers['sentry-trace'];
84+
const rawBaggageString = req.headers && req.headers.baggage;
85+
const traceparentData =
86+
typeof sentryTraceHeader === 'string' ? extractTraceparentData(sentryTraceHeader) : undefined;
87+
88+
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(rawBaggageString);
89+
90+
if (platformSupportsStreaming()) {
91+
if (requestTransaction === undefined) {
92+
const newTransaction = startTransaction(
93+
{
94+
op: 'http.server',
95+
name: options.requestedRouteName,
96+
...traceparentData,
97+
status: 'ok',
98+
metadata: {
99+
request: req,
100+
source: 'route',
101+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
102+
},
98103
},
99-
},
100-
{ request: req },
101-
);
104+
{ request: req },
105+
);
102106

103-
requestTransaction = newTransaction;
104-
autoEndTransactionOnResponseEnd(newTransaction, res);
107+
requestTransaction = newTransaction;
105108

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

112-
const dataFetcherSpan = requestTransaction.startChild({
113-
op: 'function.nextjs',
114-
description: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
115-
status: 'ok',
116-
});
114+
// Link the transaction and the request together, so that when we would normally only have access to one, it's
115+
// still possible to grab the other.
116+
setTransactionOnRequest(newTransaction, req);
117+
}
118+
119+
dataFetcherSpan = requestTransaction.startChild({
120+
op: 'function.nextjs',
121+
description: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
122+
status: 'ok',
123+
});
124+
} else {
125+
dataFetcherSpan = startTransaction({
126+
op: 'function.nextjs',
127+
name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`,
128+
...traceparentData,
129+
status: 'ok',
130+
metadata: {
131+
request: req,
132+
source: 'route',
133+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
134+
},
135+
});
136+
}
117137

118138
const currentScope = getCurrentHub().getScope();
119139
if (currentScope) {
@@ -127,15 +147,13 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
127147
// Since we finish the span before the error can bubble up and trigger the handlers in `registerErrorInstrumentation`
128148
// that set the transaction status, we need to manually set the status of the span & transaction
129149
dataFetcherSpan.setStatus('internal_error');
130-
131-
const transaction = dataFetcherSpan.transaction;
132-
if (transaction) {
133-
transaction.setStatus('internal_error');
134-
}
135-
150+
requestTransaction?.setStatus('internal_error');
136151
throw e;
137152
} finally {
138153
dataFetcherSpan.finish();
154+
if (!platformSupportsStreaming()) {
155+
await flushQueue();
156+
}
139157
}
140158
})();
141159
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const platformSupportsStreaming = (): boolean => !process.env.LAMBDA_TASK_ROOT && !process.env.VERCEL;

packages/nextjs/test/config/withSentry.test.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as hub from '@sentry/core';
22
import * as Sentry from '@sentry/node';
33
import { Client, ClientOptions } from '@sentry/types';
4-
import * as utils from '@sentry/utils';
54
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
65

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

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

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

53-
const noShoesError = new Error('Oh, no! Charlie ate the flip-flops! :-(');
54-
5543
const origHandlerNoError: NextApiHandler = async (_req, res) => {
5644
res.send('Good dog, Maisey!');
5745
};
58-
const origHandlerWithError: NextApiHandler = async (_req, _res) => {
59-
throw noShoesError;
60-
};
6146

6247
const wrappedHandlerNoError = withSentry(origHandlerNoError);
63-
const wrappedHandlerWithError = withSentry(origHandlerWithError);
6448

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

81-
describe('flushing', () => {
82-
it('flushes events before rethrowing error', async () => {
83-
try {
84-
await callWrappedHandler(wrappedHandlerWithError, req, res);
85-
} catch (err) {
86-
expect(err).toBe(noShoesError);
87-
}
88-
89-
expect(captureExceptionSpy).toHaveBeenCalledWith(noShoesError);
90-
expect(flushSpy).toHaveBeenCalled();
91-
expect(loggerSpy).toHaveBeenCalledWith('Done flushing events');
92-
93-
// This ensures the expect inside the `catch` block actually ran, i.e., that in the end the wrapped handler
94-
// errored out the same way it would without sentry, meaning the error was indeed rethrown
95-
expect.assertions(4);
96-
});
97-
98-
it('flushes events before finishing non-erroring response', async () => {
99-
jest
100-
.spyOn(hub.Hub.prototype, 'getClient')
101-
.mockReturnValueOnce({ getOptions: () => ({ tracesSampleRate: 1 } as ClientOptions) } as Client);
102-
103-
await callWrappedHandler(wrappedHandlerNoError, req, res);
104-
105-
expect(flushSpy).toHaveBeenCalled();
106-
expect(loggerSpy).toHaveBeenCalledWith('Done flushing events');
107-
});
108-
});
109-
11065
describe('tracing', () => {
11166
it('starts a transaction and sets metadata when tracing is enabled', async () => {
11267
jest

packages/nextjs/test/config/wrappers.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
} from '../../src/config/wrappers';
1515

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

1918
describe('data-fetching function wrappers', () => {
2019
const route = '/tricks/[trickName]';
@@ -43,16 +42,14 @@ describe('data-fetching function wrappers', () => {
4342
expect.objectContaining({
4443
name: '/tricks/[trickName]',
4544
op: 'http.server',
46-
metadata: expect.objectContaining({ source: 'route' }),
45+
metadata: expect.objectContaining({ source: 'route', request: req }),
4746
}),
4847
{
4948
request: expect.objectContaining({
5049
url: 'http://dogs.are.great/tricks/kangaroo',
5150
}),
5251
},
5352
);
54-
55-
expect(setMetadataSpy).toHaveBeenCalledWith({ request: req });
5653
});
5754

5855
test('withSentryServerSideGetInitialProps', async () => {
@@ -65,16 +62,14 @@ describe('data-fetching function wrappers', () => {
6562
expect.objectContaining({
6663
name: '/tricks/[trickName]',
6764
op: 'http.server',
68-
metadata: expect.objectContaining({ source: 'route' }),
65+
metadata: expect.objectContaining({ source: 'route', request: req }),
6966
}),
7067
{
7168
request: expect.objectContaining({
7269
url: 'http://dogs.are.great/tricks/kangaroo',
7370
}),
7471
},
7572
);
76-
77-
expect(setMetadataSpy).toHaveBeenCalledWith({ request: req });
7873
});
7974
});
8075
});

0 commit comments

Comments
 (0)