Skip to content

Commit 66ebf2f

Browse files
authored
fix(nextjs): Don't share I/O resources in between requests (#6980)
1 parent 56f6c7c commit 66ebf2f

File tree

5 files changed

+128
-22
lines changed

5 files changed

+128
-22
lines changed

packages/nextjs/src/edge/transport.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createTransport } from '@sentry/core';
22
import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types';
3+
import { SentryError } from '@sentry/utils';
34

45
export interface EdgeTransportOptions extends BaseTransportOptions {
56
/** Fetch API init parameters. Used by the FetchTransport */
@@ -8,6 +9,66 @@ export interface EdgeTransportOptions extends BaseTransportOptions {
89
headers?: { [key: string]: string };
910
}
1011

12+
const DEFAULT_TRANSPORT_BUFFER_SIZE = 30;
13+
14+
/**
15+
* This is a modified promise buffer that collects tasks until drain is called.
16+
* We need this in the edge runtime because edge function invocations may not share I/O objects, like fetch requests
17+
* and responses, and the normal PromiseBuffer inherently buffers stuff inbetween incoming requests.
18+
*
19+
* A limitation we need to be aware of is that DEFAULT_TRANSPORT_BUFFER_SIZE is the maximum amount of payloads the
20+
* SDK can send for a given edge function invocation.
21+
*/
22+
export class IsolatedPromiseBuffer {
23+
// We just have this field because the promise buffer interface requires it.
24+
// If we ever remove it from the interface we should also remove it here.
25+
public $: Array<PromiseLike<TransportMakeRequestResponse>> = [];
26+
27+
private _taskProducers: (() => PromiseLike<TransportMakeRequestResponse>)[] = [];
28+
29+
public constructor(private readonly _bufferSize: number = DEFAULT_TRANSPORT_BUFFER_SIZE) {}
30+
31+
/**
32+
* @inheritdoc
33+
*/
34+
public add(taskProducer: () => PromiseLike<TransportMakeRequestResponse>): PromiseLike<void> {
35+
if (this._taskProducers.length >= this._bufferSize) {
36+
return Promise.reject(new SentryError('Not adding Promise because buffer limit was reached.'));
37+
}
38+
39+
this._taskProducers.push(taskProducer);
40+
return Promise.resolve();
41+
}
42+
43+
/**
44+
* @inheritdoc
45+
*/
46+
public drain(timeout?: number): PromiseLike<boolean> {
47+
const oldTaskProducers = [...this._taskProducers];
48+
this._taskProducers = [];
49+
50+
return new Promise(resolve => {
51+
const timer = setTimeout(() => {
52+
if (timeout && timeout > 0) {
53+
resolve(false);
54+
}
55+
}, timeout);
56+
57+
void Promise.all(
58+
oldTaskProducers.map(taskProducer =>
59+
taskProducer().then(null, () => {
60+
// catch all failed requests
61+
}),
62+
),
63+
).then(() => {
64+
// resolve to true if all fetch requests settled
65+
clearTimeout(timer);
66+
resolve(true);
67+
});
68+
});
69+
}
70+
}
71+
1172
/**
1273
* Creates a Transport that uses the Edge Runtimes native fetch API to send events to Sentry.
1374
*/
@@ -21,18 +82,16 @@ export function makeEdgeTransport(options: EdgeTransportOptions): Transport {
2182
...options.fetchOptions,
2283
};
2384

24-
try {
25-
return fetch(options.url, requestOptions).then(response => ({
85+
return fetch(options.url, requestOptions).then(response => {
86+
return {
2687
statusCode: response.status,
2788
headers: {
2889
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
2990
'retry-after': response.headers.get('Retry-After'),
3091
},
31-
}));
32-
} catch (e) {
33-
return Promise.reject(e);
34-
}
92+
};
93+
});
3594
}
3695

37-
return createTransport(options, makeRequest);
96+
return createTransport(options, makeRequest, new IsolatedPromiseBuffer(options.bufferSize));
3897
}

packages/nextjs/src/edge/utils/edgeWrapperUtils.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,15 @@ export function withEdgeWrapping<H extends EdgeRouteHandler>(
4444

4545
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(req.headers.get('baggage'));
4646

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-
},
47+
span = startTransaction({
48+
name: options.spanDescription,
49+
op: options.spanOp,
50+
...traceparentData,
51+
metadata: {
52+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
53+
source: 'route',
5654
},
57-
// extra context passed to the `tracesSampler`
58-
{ request: req },
59-
);
55+
});
6056
}
6157

6258
currentScope?.setSpan(span);

packages/nextjs/test/edge/edgeWrapperUtils.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ describe('withEdgeWrapping', () => {
8484
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
8585
expect(startTransactionSpy).toHaveBeenCalledWith(
8686
expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }),
87-
{ request },
8887
);
8988
});
9089

packages/nextjs/test/edge/transport.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createEnvelope, serializeEnvelope } from '@sentry/utils';
33
import { TextEncoder } from 'util';
44

55
import type { EdgeTransportOptions } from '../../src/edge/transport';
6-
import { makeEdgeTransport } from '../../src/edge/transport';
6+
import { IsolatedPromiseBuffer, makeEdgeTransport } from '../../src/edge/transport';
77

88
const DEFAULT_EDGE_TRANSPORT_OPTIONS: EdgeTransportOptions = {
99
url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7',
@@ -51,6 +51,7 @@ describe('Edge Transport', () => {
5151

5252
expect(mockFetch).toHaveBeenCalledTimes(0);
5353
await transport.send(ERROR_ENVELOPE);
54+
await transport.flush();
5455
expect(mockFetch).toHaveBeenCalledTimes(1);
5556

5657
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, {
@@ -77,6 +78,7 @@ describe('Edge Transport', () => {
7778

7879
expect(headers.get).toHaveBeenCalledTimes(0);
7980
await transport.send(ERROR_ENVELOPE);
81+
await transport.flush();
8082

8183
expect(headers.get).toHaveBeenCalledTimes(2);
8284
expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits');
@@ -101,10 +103,61 @@ describe('Edge Transport', () => {
101103
const transport = makeEdgeTransport({ ...DEFAULT_EDGE_TRANSPORT_OPTIONS, fetchOptions: REQUEST_OPTIONS });
102104

103105
await transport.send(ERROR_ENVELOPE);
106+
await transport.flush();
104107
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, {
105108
body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()),
106109
method: 'POST',
107110
...REQUEST_OPTIONS,
108111
});
109112
});
110113
});
114+
115+
describe('IsolatedPromiseBuffer', () => {
116+
it('should not call tasks until drained', async () => {
117+
const ipb = new IsolatedPromiseBuffer();
118+
119+
const task1 = jest.fn(() => Promise.resolve({}));
120+
const task2 = jest.fn(() => Promise.resolve({}));
121+
122+
await ipb.add(task1);
123+
await ipb.add(task2);
124+
125+
expect(task1).not.toHaveBeenCalled();
126+
expect(task2).not.toHaveBeenCalled();
127+
128+
await ipb.drain();
129+
130+
expect(task1).toHaveBeenCalled();
131+
expect(task2).toHaveBeenCalled();
132+
});
133+
134+
it('should not allow adding more items than the specified limit', async () => {
135+
const ipb = new IsolatedPromiseBuffer(3);
136+
137+
const task1 = jest.fn(() => Promise.resolve({}));
138+
const task2 = jest.fn(() => Promise.resolve({}));
139+
const task3 = jest.fn(() => Promise.resolve({}));
140+
const task4 = jest.fn(() => Promise.resolve({}));
141+
142+
await ipb.add(task1);
143+
await ipb.add(task2);
144+
await ipb.add(task3);
145+
146+
await expect(ipb.add(task4)).rejects.toThrowError('Not adding Promise because buffer limit was reached.');
147+
});
148+
149+
it('should not throw when one of the tasks throws when drained', async () => {
150+
const ipb = new IsolatedPromiseBuffer();
151+
152+
const task1 = jest.fn(() => Promise.resolve({}));
153+
const task2 = jest.fn(() => Promise.reject(new Error()));
154+
155+
await ipb.add(task1);
156+
await ipb.add(task2);
157+
158+
await expect(ipb.drain()).resolves.toEqual(true);
159+
160+
expect(task1).toHaveBeenCalled();
161+
expect(task2).toHaveBeenCalled();
162+
});
163+
});

packages/nextjs/test/edge/withSentryAPI.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ describe('wrapApiHandlerWithSentry', () => {
5353
name: 'POST /user/[userId]/post/[postId]',
5454
op: 'http.server',
5555
}),
56-
{ request },
5756
);
5857
});
5958

0 commit comments

Comments
 (0)