Skip to content

Commit 5791c49

Browse files
authored
feat(node): Compression support for http transport (#5209)
Note: This is an exact duplicate of #5139 and #5203, both originally by @timfish, which accidentally got merged into the 7.x branch rather than master. #5139: This is mostly stolen straight from the [Electron transport](https://github.com/getsentry/sentry-electron/blob/master/src/main/transports/electron-net.ts). This will help with attachments! #5203: #5139 introduced a subtle bug where `options.headers` was modified which causes headers to leak between requests. This means requests after a compressed request will be incorrectly marked with `content-encoding: gzip`.
1 parent 329ca06 commit 5791c49

File tree

3 files changed

+165
-100
lines changed

3 files changed

+165
-100
lines changed

packages/node/src/transports/http-module.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IncomingHttpHeaders, RequestOptions as HTTPRequestOptions } from 'http';
22
import { RequestOptions as HTTPSRequestOptions } from 'https';
3+
import { Writable } from 'stream';
34
import { URL } from 'url';
45

56
export type HTTPModuleRequestOptions = HTTPRequestOptions | HTTPSRequestOptions | string | URL;
@@ -15,15 +16,6 @@ export interface HTTPModuleRequestIncomingMessage {
1516
setEncoding(encoding: string): void;
1617
}
1718

18-
/**
19-
* Cut version of http.ClientRequest.
20-
* Some transports work in a special Javascript environment where http.IncomingMessage is not available.
21-
*/
22-
export interface HTTPModuleClientRequest {
23-
end(chunk: string | Uint8Array): void;
24-
on(event: 'error', listener: () => void): void;
25-
}
26-
2719
/**
2820
* Internal used interface for typescript.
2921
* @hidden
@@ -34,10 +26,7 @@ export interface HTTPModule {
3426
* @param options These are {@see TransportOptions}
3527
* @param callback Callback when request is finished
3628
*/
37-
request(
38-
options: HTTPModuleRequestOptions,
39-
callback?: (res: HTTPModuleRequestIncomingMessage) => void,
40-
): HTTPModuleClientRequest;
29+
request(options: HTTPModuleRequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void): Writable;
4130

4231
// This is the type for nodejs versions that handle the URL argument
4332
// (v10.9.0+), but we do not use it just yet because we support older node

packages/node/src/transports/http.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
} from '@sentry/types';
99
import * as http from 'http';
1010
import * as https from 'https';
11+
import { Readable } from 'stream';
1112
import { URL } from 'url';
13+
import { createGzip } from 'zlib';
1214

1315
import { HTTPModule } from './http-module';
1416

@@ -23,6 +25,22 @@ export interface NodeTransportOptions extends BaseTransportOptions {
2325
httpModule?: HTTPModule;
2426
}
2527

28+
// Estimated maximum size for reasonable standalone event
29+
const GZIP_THRESHOLD = 1024 * 32;
30+
31+
/**
32+
* Gets a stream from a Uint8Array or string
33+
* Readable.from is ideal but was added in node.js v12.3.0 and v10.17.0
34+
*/
35+
function streamFromBody(body: Uint8Array | string): Readable {
36+
return new Readable({
37+
read() {
38+
this.push(body);
39+
this.push(null);
40+
},
41+
});
42+
}
43+
2644
/**
2745
* Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry.
2846
*/
@@ -85,11 +103,20 @@ function createRequestExecutor(
85103
const { hostname, pathname, port, protocol, search } = new URL(options.url);
86104
return function makeRequest(request: TransportRequest): Promise<TransportMakeRequestResponse> {
87105
return new Promise((resolve, reject) => {
106+
let body = streamFromBody(request.body);
107+
108+
const headers: Record<string, string> = { ...options.headers };
109+
110+
if (request.body.length > GZIP_THRESHOLD) {
111+
headers['content-encoding'] = 'gzip';
112+
body = body.pipe(createGzip());
113+
}
114+
88115
const req = httpModule.request(
89116
{
90117
method: 'POST',
91118
agent,
92-
headers: options.headers,
119+
headers,
93120
hostname,
94121
path: `${pathname}${search}`,
95122
port,
@@ -123,7 +150,7 @@ function createRequestExecutor(
123150
);
124151

125152
req.on('error', reject);
126-
req.end(request.body);
153+
body.pipe(req);
127154
});
128155
};
129156
}

packages/node/test/transports/http.test.ts

Lines changed: 134 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createTransport } from '@sentry/core';
22
import { EventEnvelope, EventItem } from '@sentry/types';
3-
import { createEnvelope, serializeEnvelope } from '@sentry/utils';
3+
import { addItemToEnvelope, createAttachmentEnvelopeItem, createEnvelope, serializeEnvelope } from '@sentry/utils';
44
import * as http from 'http';
55
import { TextEncoder } from 'util';
6+
import { createGunzip } from 'zlib';
67

78
import { makeNodeTransport } from '../../src/transports';
89

@@ -34,17 +35,19 @@ let testServer: http.Server | undefined;
3435

3536
function setupTestServer(
3637
options: TestServerOptions,
37-
requestInspector?: (req: http.IncomingMessage, body: string) => void,
38+
requestInspector?: (req: http.IncomingMessage, body: string, raw: Uint8Array) => void,
3839
) {
3940
testServer = http.createServer((req, res) => {
40-
let body = '';
41+
const chunks: Buffer[] = [];
4142

42-
req.on('data', data => {
43-
body += data;
43+
const stream = req.headers['content-encoding'] === 'gzip' ? req.pipe(createGunzip({})) : req;
44+
45+
stream.on('data', data => {
46+
chunks.push(data);
4447
});
4548

46-
req.on('end', () => {
47-
requestInspector?.(req, body);
49+
stream.on('end', () => {
50+
requestInspector?.(req, chunks.join(), Buffer.concat(chunks));
4851
});
4952

5053
res.writeHead(options.statusCode, options.responseHeaders);
@@ -69,6 +72,16 @@ const EVENT_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4b
6972

7073
const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE, new TextEncoder());
7174

75+
const ATTACHMENT_ITEM = createAttachmentEnvelopeItem(
76+
{ filename: 'empty-file.bin', data: new Uint8Array(50_000) },
77+
new TextEncoder(),
78+
);
79+
const EVENT_ATTACHMENT_ENVELOPE = addItemToEnvelope(EVENT_ENVELOPE, ATTACHMENT_ITEM);
80+
const SERIALIZED_EVENT_ATTACHMENT_ENVELOPE = serializeEnvelope(
81+
EVENT_ATTACHMENT_ENVELOPE,
82+
new TextEncoder(),
83+
) as Uint8Array;
84+
7285
const defaultOptions = {
7386
url: TEST_SERVER_URL,
7487
recordDroppedEvent: () => undefined,
@@ -155,6 +168,40 @@ describe('makeNewHttpTransport()', () => {
155168
});
156169
});
157170

171+
describe('compression', () => {
172+
it('small envelopes should not be compressed', async () => {
173+
await setupTestServer(
174+
{
175+
statusCode: SUCCESS,
176+
responseHeaders: {},
177+
},
178+
(req, body) => {
179+
expect(req.headers['content-encoding']).toBeUndefined();
180+
expect(body).toBe(SERIALIZED_EVENT_ENVELOPE);
181+
},
182+
);
183+
184+
const transport = makeNodeTransport(defaultOptions);
185+
await transport.send(EVENT_ENVELOPE);
186+
});
187+
188+
it('large envelopes should be compressed', async () => {
189+
await setupTestServer(
190+
{
191+
statusCode: SUCCESS,
192+
responseHeaders: {},
193+
},
194+
(req, _, raw) => {
195+
expect(req.headers['content-encoding']).toEqual('gzip');
196+
expect(raw.buffer).toStrictEqual(SERIALIZED_EVENT_ATTACHMENT_ENVELOPE.buffer);
197+
},
198+
);
199+
200+
const transport = makeNodeTransport(defaultOptions);
201+
await transport.send(EVENT_ATTACHMENT_ENVELOPE);
202+
});
203+
});
204+
158205
describe('proxy', () => {
159206
it('can be configured through option', () => {
160207
makeNodeTransport({
@@ -236,104 +283,106 @@ describe('makeNewHttpTransport()', () => {
236283
});
237284
});
238285

239-
it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => {
240-
await setupTestServer({
241-
statusCode: RATE_LIMIT,
242-
responseHeaders: {},
243-
});
244-
245-
makeNodeTransport(defaultOptions);
246-
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
247-
248-
const executorResult = registeredRequestExecutor({
249-
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
250-
category: 'error',
251-
});
252-
253-
await expect(executorResult).resolves.toEqual(
254-
expect.objectContaining({
286+
describe('should register TransportRequestExecutor that returns the correct object from server response', () => {
287+
it('rate limit', async () => {
288+
await setupTestServer({
255289
statusCode: RATE_LIMIT,
256-
}),
257-
);
258-
});
290+
responseHeaders: {},
291+
});
259292

260-
it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => {
261-
await setupTestServer({
262-
statusCode: SUCCESS,
263-
});
293+
makeNodeTransport(defaultOptions);
294+
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
264295

265-
makeNodeTransport(defaultOptions);
266-
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
296+
const executorResult = registeredRequestExecutor({
297+
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
298+
category: 'error',
299+
});
267300

268-
const executorResult = registeredRequestExecutor({
269-
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
270-
category: 'error',
301+
await expect(executorResult).resolves.toEqual(
302+
expect.objectContaining({
303+
statusCode: RATE_LIMIT,
304+
}),
305+
);
271306
});
272307

273-
await expect(executorResult).resolves.toEqual(
274-
expect.objectContaining({
308+
it('OK', async () => {
309+
await setupTestServer({
275310
statusCode: SUCCESS,
276-
headers: {
277-
'retry-after': null,
278-
'x-sentry-rate-limits': null,
279-
},
280-
}),
281-
);
282-
});
311+
});
283312

284-
it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => {
285-
await setupTestServer({
286-
statusCode: SUCCESS,
287-
responseHeaders: {
288-
'Retry-After': '2700',
289-
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
290-
},
291-
});
313+
makeNodeTransport(defaultOptions);
314+
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
292315

293-
makeNodeTransport(defaultOptions);
294-
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
316+
const executorResult = registeredRequestExecutor({
317+
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
318+
category: 'error',
319+
});
295320

296-
const executorResult = registeredRequestExecutor({
297-
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
298-
category: 'error',
321+
await expect(executorResult).resolves.toEqual(
322+
expect.objectContaining({
323+
statusCode: SUCCESS,
324+
headers: {
325+
'retry-after': null,
326+
'x-sentry-rate-limits': null,
327+
},
328+
}),
329+
);
299330
});
300331

301-
await expect(executorResult).resolves.toEqual(
302-
expect.objectContaining({
332+
it('OK with rate-limit headers', async () => {
333+
await setupTestServer({
303334
statusCode: SUCCESS,
304-
headers: {
305-
'retry-after': '2700',
306-
'x-sentry-rate-limits': '60::organization, 2700::organization',
335+
responseHeaders: {
336+
'Retry-After': '2700',
337+
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
307338
},
308-
}),
309-
);
310-
});
339+
});
311340

312-
it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => {
313-
await setupTestServer({
314-
statusCode: RATE_LIMIT,
315-
responseHeaders: {
316-
'Retry-After': '2700',
317-
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
318-
},
319-
});
341+
makeNodeTransport(defaultOptions);
342+
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
320343

321-
makeNodeTransport(defaultOptions);
322-
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
344+
const executorResult = registeredRequestExecutor({
345+
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
346+
category: 'error',
347+
});
323348

324-
const executorResult = registeredRequestExecutor({
325-
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
326-
category: 'error',
349+
await expect(executorResult).resolves.toEqual(
350+
expect.objectContaining({
351+
statusCode: SUCCESS,
352+
headers: {
353+
'retry-after': '2700',
354+
'x-sentry-rate-limits': '60::organization, 2700::organization',
355+
},
356+
}),
357+
);
327358
});
328359

329-
await expect(executorResult).resolves.toEqual(
330-
expect.objectContaining({
360+
it('NOK with rate-limit headers', async () => {
361+
await setupTestServer({
331362
statusCode: RATE_LIMIT,
332-
headers: {
333-
'retry-after': '2700',
334-
'x-sentry-rate-limits': '60::organization, 2700::organization',
363+
responseHeaders: {
364+
'Retry-After': '2700',
365+
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
335366
},
336-
}),
337-
);
367+
});
368+
369+
makeNodeTransport(defaultOptions);
370+
const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1];
371+
372+
const executorResult = registeredRequestExecutor({
373+
body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()),
374+
category: 'error',
375+
});
376+
377+
await expect(executorResult).resolves.toEqual(
378+
expect.objectContaining({
379+
statusCode: RATE_LIMIT,
380+
headers: {
381+
'retry-after': '2700',
382+
'x-sentry-rate-limits': '60::organization, 2700::organization',
383+
},
384+
}),
385+
);
386+
});
338387
});
339388
});

0 commit comments

Comments
 (0)