Skip to content

Commit a0145e6

Browse files
feat: Option to use gzip to compress event
1 parent 20e6e63 commit a0145e6

File tree

11 files changed

+164
-67
lines changed

11 files changed

+164
-67
lines changed

contract-tests/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ app.get('/', (req, res) => {
3939
'evaluation-hooks',
4040
'wrapper',
4141
'client-prereq-events',
42+
'event-gzip',
43+
'optional-event-gzip',
4244
],
4345
});
4446
});

contract-tests/sdkClientEntity.js

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function makeSdkConfig(options, tag) {
4545
cf.diagnosticOptOut = !options.events.enableDiagnostics;
4646
cf.flushInterval = maybeTime(options.events.flushIntervalMs);
4747
cf.privateAttributes = options.events.globalPrivateAttributes;
48+
cf.enableEventCompression = options.events.enableGzip;
4849
}
4950
if (options.tags) {
5051
cf.application = {

packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts

+96-55
Original file line numberDiff line numberDiff line change
@@ -8,73 +8,76 @@ const TEXT_RESPONSE = 'Test Text';
88
const JSON_RESPONSE = '{"text": "value"}';
99

1010
interface TestRequestData {
11-
body: string;
11+
body: string | Buffer;
1212
method: string | undefined;
1313
headers: http.IncomingHttpHeaders;
1414
}
1515

16-
describe('given a default instance of NodeRequests', () => {
17-
let resolve: (value: TestRequestData | PromiseLike<TestRequestData>) => void;
18-
let promise: Promise<TestRequestData>;
19-
let server: http.Server;
20-
let resetResolve: () => void;
21-
let resetPromise: Promise<void>;
22-
23-
beforeEach(() => {
24-
resetPromise = new Promise((res) => {
25-
resetResolve = res;
26-
});
16+
let resolve: (value: TestRequestData | PromiseLike<TestRequestData>) => void;
17+
let promise: Promise<TestRequestData>;
18+
let server: http.Server;
19+
let resetResolve: () => void;
20+
let resetPromise: Promise<void>;
2721

28-
promise = new Promise<TestRequestData>((res) => {
29-
resolve = res;
22+
beforeEach(() => {
23+
resetPromise = new Promise((res) => {
24+
resetResolve = res;
25+
});
26+
27+
promise = new Promise<TestRequestData>((res) => {
28+
resolve = res;
29+
});
30+
server = http.createServer({ keepAlive: false }, (req, res) => {
31+
const chunks: any[] = [];
32+
req.on('data', (chunk) => {
33+
chunks.push(chunk);
3034
});
31-
server = http.createServer({ keepAlive: false }, (req, res) => {
32-
const chunks: any[] = [];
33-
req.on('data', (chunk) => {
34-
chunks.push(chunk);
35-
});
36-
req.on('end', () => {
37-
resolve({
38-
method: req.method,
39-
body: Buffer.concat(chunks).toString(),
40-
headers: req.headers,
41-
});
35+
req.on('end', () => {
36+
resolve({
37+
method: req.method,
38+
body:
39+
req.headers['content-encoding'] === 'gzip'
40+
? Buffer.concat(chunks)
41+
: Buffer.concat(chunks).toString(),
42+
headers: req.headers,
4243
});
44+
});
45+
res.statusCode = 200;
46+
res.setHeader('Content-Type', 'text/plain');
47+
res.setHeader('Connection', 'close');
48+
if ((req.url?.indexOf('json') || -1) >= 0) {
49+
res.end(JSON_RESPONSE);
50+
} else if ((req.url?.indexOf('interrupt') || -1) >= 0) {
51+
res.destroy();
52+
} else if ((req.url?.indexOf('404') || -1) >= 0) {
53+
res.statusCode = 404;
54+
res.end();
55+
} else if ((req.url?.indexOf('reset') || -1) >= 0) {
4356
res.statusCode = 200;
44-
res.setHeader('Content-Type', 'text/plain');
45-
res.setHeader('Connection', 'close');
46-
if ((req.url?.indexOf('json') || -1) >= 0) {
47-
res.end(JSON_RESPONSE);
48-
} else if ((req.url?.indexOf('interrupt') || -1) >= 0) {
57+
res.flushHeaders();
58+
res.write('potato');
59+
setTimeout(() => {
4960
res.destroy();
50-
} else if ((req.url?.indexOf('404') || -1) >= 0) {
51-
res.statusCode = 404;
52-
res.end();
53-
} else if ((req.url?.indexOf('reset') || -1) >= 0) {
54-
res.statusCode = 200;
55-
res.flushHeaders();
56-
res.write('potato');
57-
setTimeout(() => {
58-
res.destroy();
59-
resetResolve();
60-
}, 0);
61-
} else if ((req.url?.indexOf('gzip') || -1) >= 0) {
62-
res.setHeader('Content-Encoding', 'gzip');
63-
res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8')));
64-
} else {
65-
res.end(TEXT_RESPONSE);
66-
}
67-
});
68-
server.listen(PORT);
61+
resetResolve();
62+
}, 0);
63+
} else if ((req.url?.indexOf('gzip') || -1) >= 0) {
64+
res.setHeader('Content-Encoding', 'gzip');
65+
res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8')));
66+
} else {
67+
res.end(TEXT_RESPONSE);
68+
}
6969
});
70+
server.listen(PORT);
71+
});
7072

71-
afterEach(
72-
async () =>
73-
new Promise((resolveClose) => {
74-
server.close(resolveClose);
75-
}),
76-
);
73+
afterEach(
74+
async () =>
75+
new Promise((resolveClose) => {
76+
server.close(resolveClose);
77+
}),
78+
);
7779

80+
describe('given a default instance of NodeRequests', () => {
7881
const requests = new NodeRequests();
7982
it('can make a basic get request', async () => {
8083
const res = await requests.fetch(`http://localhost:${PORT}`);
@@ -120,6 +123,17 @@ describe('given a default instance of NodeRequests', () => {
120123
expect(serverResult.body).toEqual('BODY TEXT');
121124
});
122125

126+
it('can make a basic post ignoring compressBodyIfPossible', async () => {
127+
await requests.fetch(`http://localhost:${PORT}`, {
128+
method: 'POST',
129+
body: 'BODY TEXT',
130+
compressBodyIfPossible: true,
131+
});
132+
const serverResult = await promise;
133+
expect(serverResult.method).toEqual('POST');
134+
expect(serverResult.body).toEqual('BODY TEXT');
135+
});
136+
123137
it('can make a request with headers', async () => {
124138
await requests.fetch(`http://localhost:${PORT}`, {
125139
method: 'POST',
@@ -166,3 +180,30 @@ describe('given a default instance of NodeRequests', () => {
166180
expect(serverResult.body).toEqual('');
167181
});
168182
});
183+
184+
describe('given an instance of NodeRequests with enableEventCompression turned on', () => {
185+
const requests = new NodeRequests(undefined, undefined, undefined, true);
186+
it('can make a basic post with compressBodyIfPossible enabled', async () => {
187+
await requests.fetch(`http://localhost:${PORT}`, {
188+
method: 'POST',
189+
body: 'BODY TEXT',
190+
compressBodyIfPossible: true,
191+
});
192+
const serverResult = await promise;
193+
expect(serverResult.method).toEqual('POST');
194+
expect(serverResult.headers['content-encoding']).toEqual('gzip');
195+
expect(serverResult.body).toEqual(zlib.gzipSync('BODY TEXT'));
196+
});
197+
198+
it('can make a basic post with compressBodyIfPossible disabled', async () => {
199+
await requests.fetch(`http://localhost:${PORT}`, {
200+
method: 'POST',
201+
body: 'BODY TEXT',
202+
compressBodyIfPossible: false,
203+
});
204+
const serverResult = await promise;
205+
expect(serverResult.method).toEqual('POST');
206+
expect(serverResult.headers['content-encoding']).toBeUndefined();
207+
expect(serverResult.body).toEqual('BODY TEXT');
208+
});
209+
});

packages/sdk/server-node/src/platform/NodePlatform.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export default class NodePlatform implements platform.Platform {
1616

1717
constructor(options: LDOptions) {
1818
this.info = new NodeInfo(options);
19-
this.requests = new NodeRequests(options.tlsParams, options.proxyOptions, options.logger);
19+
this.requests = new NodeRequests(
20+
options.tlsParams,
21+
options.proxyOptions,
22+
options.logger,
23+
options.enableEventCompression,
24+
);
2025
}
2126
}

packages/sdk/server-node/src/platform/NodeRequests.ts

+34-11
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent';
55
// No types for the event source.
66
// @ts-ignore
77
import { EventSource as LDEventSource } from 'launchdarkly-eventsource';
8+
import { promisify } from 'util';
9+
import * as zlib from 'zlib';
810

911
import {
1012
EventSourceCapabilities,
@@ -16,6 +18,8 @@ import {
1618

1719
import NodeResponse from './NodeResponse';
1820

21+
const gzip = promisify(zlib.gzip);
22+
1923
function processTlsOptions(tlsOptions: LDTLSOptions): https.AgentOptions {
2024
const options: https.AgentOptions & { [index: string]: any } = {
2125
ca: tlsOptions.ca,
@@ -101,25 +105,44 @@ export default class NodeRequests implements platform.Requests {
101105

102106
private _hasProxyAuth: boolean = false;
103107

104-
constructor(tlsOptions?: LDTLSOptions, proxyOptions?: LDProxyOptions, logger?: LDLogger) {
108+
private _enableEventCompression: boolean = false;
109+
110+
constructor(
111+
tlsOptions?: LDTLSOptions,
112+
proxyOptions?: LDProxyOptions,
113+
logger?: LDLogger,
114+
enableEventCompression?: boolean,
115+
) {
105116
this._agent = createAgent(tlsOptions, proxyOptions, logger);
106117
this._hasProxy = !!proxyOptions;
107118
this._hasProxyAuth = !!proxyOptions?.auth;
119+
this._enableEventCompression = !!enableEventCompression;
108120
}
109121

110-
fetch(url: string, options: platform.Options = {}): Promise<platform.Response> {
122+
async fetch(url: string, options: platform.Options = {}): Promise<platform.Response> {
111123
const isSecure = url.startsWith('https://');
112124
const impl = isSecure ? https : http;
113125

126+
const headers = { ...options.headers };
127+
let bodyData: String | Buffer | undefined = options.body;
128+
114129
// For get requests we are going to automatically support compressed responses.
115130
// Note this does not affect SSE as the event source is not using this fetch implementation.
116-
const headers =
117-
options.method?.toLowerCase() === 'get'
118-
? {
119-
...options.headers,
120-
'accept-encoding': 'gzip',
121-
}
122-
: options.headers;
131+
if (options.method?.toLowerCase() === 'get') {
132+
headers['accept-encoding'] = 'gzip';
133+
}
134+
// For post requests we are going to support compressed post bodies if the
135+
// enableEventCompression config setting is true and the compressBodyIfPossible
136+
// option is true.
137+
else if (
138+
this._enableEventCompression &&
139+
!!options.compressBodyIfPossible &&
140+
options.method?.toLowerCase() === 'post' &&
141+
options.body
142+
) {
143+
headers['content-encoding'] = 'gzip';
144+
bodyData = await gzip(Buffer.from(options.body, 'utf8'));
145+
}
123146

124147
return new Promise((resolve, reject) => {
125148
const req = impl.request(
@@ -133,8 +156,8 @@ export default class NodeRequests implements platform.Requests {
133156
(res) => resolve(new NodeResponse(res)),
134157
);
135158

136-
if (options.body) {
137-
req.write(options.body);
159+
if (bodyData) {
160+
req.write(bodyData);
138161
}
139162

140163
req.on('error', (err) => {

packages/shared/common/__tests__/internal/events/EventSender.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ describe('given an event sender', () => {
133133
expect(mockFetch).toHaveBeenCalledTimes(1);
134134
expect(mockFetch).toHaveBeenCalledWith(`${basicConfig.serviceEndpoints.events}/bulk`, {
135135
body: JSON.stringify(testEventData1),
136+
compressBodyIfPossible: true,
136137
headers: analyticsHeaders(uuid),
137138
method: 'POST',
138139
keepalive: true,
@@ -150,6 +151,7 @@ describe('given an event sender', () => {
150151
expect(mockFetch).toHaveBeenCalledTimes(2);
151152
expect(mockFetch).toHaveBeenNthCalledWith(1, `${basicConfig.serviceEndpoints.events}/bulk`, {
152153
body: JSON.stringify(testEventData1),
154+
compressBodyIfPossible: true,
153155
headers: analyticsHeaders(uuid),
154156
method: 'POST',
155157
keepalive: true,
@@ -159,6 +161,7 @@ describe('given an event sender', () => {
159161
`${basicConfig.serviceEndpoints.events}/diagnostic`,
160162
{
161163
body: JSON.stringify(testEventData2),
164+
compressBodyIfPossible: true,
162165
headers: diagnosticHeaders,
163166
method: 'POST',
164167
keepalive: true,

packages/shared/common/src/api/platform/Requests.ts

+5
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ export interface Options {
7575
headers?: Record<string, string>;
7676
method?: string;
7777
body?: string;
78+
/**
79+
* Gzip compress the post body only if the underlying SDK framework supports it
80+
* and the config option enableEventCompression is set to true.
81+
*/
82+
compressBodyIfPossible?: boolean;
7883
timeout?: number;
7984
/**
8085
* For use in browser environments. Platform support will be best effort for this field.

packages/shared/common/src/internal/events/EventSender.ts

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default class EventSender implements LDEventSender {
6464
const { status, headers: resHeaders } = await this._requests.fetch(uri, {
6565
headers,
6666
body: JSON.stringify(events),
67+
compressBodyIfPossible: true,
6768
method: 'POST',
6869
// When sending events from browser environments the request should be completed even
6970
// if the user is navigating away from the page.

packages/shared/sdk-server/src/api/options/LDOptions.ts

+10
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,14 @@ export interface LDOptions {
304304
* ```
305305
*/
306306
hooks?: Hook[];
307+
308+
/**
309+
* Set to true to opt in to compressing event payloads if the SDK supports it, since the
310+
* compression library may not be supported in the underlying SDK framework. If the compression
311+
* library is not supported then event payloads will not be compressed even if this option
312+
* is enabled.
313+
*
314+
* Defaults to false.
315+
*/
316+
enableEventCompression?: boolean;
307317
}

packages/shared/sdk-server/src/options/Configuration.ts

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const validations: Record<string, TypeValidator> = {
5757
application: TypeValidators.Object,
5858
payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
5959
hooks: TypeValidators.createTypeArray('Hook[]', {}),
60+
enableEventCompression: TypeValidators.Boolean,
6061
};
6162

6263
/**
@@ -82,6 +83,7 @@ export const defaultValues: ValidatedOptions = {
8283
diagnosticOptOut: false,
8384
diagnosticRecordingInterval: 900,
8485
featureStore: () => new InMemoryFeatureStore(),
86+
enableEventCompression: false,
8587
};
8688

8789
function validateTypesAndNames(options: LDOptions): {
@@ -215,6 +217,8 @@ export default class Configuration {
215217

216218
public readonly hooks?: Hook[];
217219

220+
public readonly enableEventCompression: boolean;
221+
218222
constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) {
219223
// The default will handle undefined, but not null.
220224
// Because we can be called from JS we need to be extra defensive.
@@ -283,5 +287,6 @@ export default class Configuration {
283287
}
284288

285289
this.hooks = validatedOptions.hooks;
290+
this.enableEventCompression = validatedOptions.enableEventCompression;
286291
}
287292
}

packages/shared/sdk-server/src/options/ValidatedOptions.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ export interface ValidatedOptions {
4141
[index: string]: any;
4242
bigSegments?: LDBigSegmentsOptions;
4343
hooks?: Hook[];
44+
enableEventCompression: boolean;
4445
}

0 commit comments

Comments
 (0)