Skip to content
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

feat: Option to use gzip to compress event #814

Merged
merged 2 commits into from
Apr 8, 2025
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
2 changes: 2 additions & 0 deletions contract-tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ app.get('/', (req, res) => {
'evaluation-hooks',
'wrapper',
'client-prereq-events',
'event-gzip',
'optional-event-gzip',
],
});
});
Expand Down
1 change: 1 addition & 0 deletions contract-tests/sdkClientEntity.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function makeSdkConfig(options, tag) {
cf.diagnosticOptOut = !options.events.enableDiagnostics;
cf.flushInterval = maybeTime(options.events.flushIntervalMs);
cf.privateAttributes = options.events.globalPrivateAttributes;
cf.enableEventCompression = options.events.enableGzip;
}
if (options.tags) {
cf.application = {
Expand Down
151 changes: 96 additions & 55 deletions packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,73 +8,76 @@ const TEXT_RESPONSE = 'Test Text';
const JSON_RESPONSE = '{"text": "value"}';

interface TestRequestData {
body: string;
body: string | Buffer;
method: string | undefined;
headers: http.IncomingHttpHeaders;
}

describe('given a default instance of NodeRequests', () => {
let resolve: (value: TestRequestData | PromiseLike<TestRequestData>) => void;
let promise: Promise<TestRequestData>;
let server: http.Server;
let resetResolve: () => void;
let resetPromise: Promise<void>;

beforeEach(() => {
resetPromise = new Promise((res) => {
resetResolve = res;
});
let resolve: (value: TestRequestData | PromiseLike<TestRequestData>) => void;
let promise: Promise<TestRequestData>;
let server: http.Server;
let resetResolve: () => void;
let resetPromise: Promise<void>;

promise = new Promise<TestRequestData>((res) => {
resolve = res;
beforeEach(() => {
resetPromise = new Promise((res) => {
resetResolve = res;
});

promise = new Promise<TestRequestData>((res) => {
resolve = res;
});
server = http.createServer({ keepAlive: false }, (req, res) => {
const chunks: any[] = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
server = http.createServer({ keepAlive: false }, (req, res) => {
const chunks: any[] = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
resolve({
method: req.method,
body: Buffer.concat(chunks).toString(),
headers: req.headers,
});
req.on('end', () => {
resolve({
method: req.method,
body:
req.headers['content-encoding'] === 'gzip'
? Buffer.concat(chunks)
: Buffer.concat(chunks).toString(),
headers: req.headers,
});
});
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Connection', 'close');
if ((req.url?.indexOf('json') || -1) >= 0) {
res.end(JSON_RESPONSE);
} else if ((req.url?.indexOf('interrupt') || -1) >= 0) {
res.destroy();
} else if ((req.url?.indexOf('404') || -1) >= 0) {
res.statusCode = 404;
res.end();
} else if ((req.url?.indexOf('reset') || -1) >= 0) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Connection', 'close');
if ((req.url?.indexOf('json') || -1) >= 0) {
res.end(JSON_RESPONSE);
} else if ((req.url?.indexOf('interrupt') || -1) >= 0) {
res.flushHeaders();
res.write('potato');
setTimeout(() => {
res.destroy();
} else if ((req.url?.indexOf('404') || -1) >= 0) {
res.statusCode = 404;
res.end();
} else if ((req.url?.indexOf('reset') || -1) >= 0) {
res.statusCode = 200;
res.flushHeaders();
res.write('potato');
setTimeout(() => {
res.destroy();
resetResolve();
}, 0);
} else if ((req.url?.indexOf('gzip') || -1) >= 0) {
res.setHeader('Content-Encoding', 'gzip');
res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8')));
} else {
res.end(TEXT_RESPONSE);
}
});
server.listen(PORT);
resetResolve();
}, 0);
} else if ((req.url?.indexOf('gzip') || -1) >= 0) {
res.setHeader('Content-Encoding', 'gzip');
res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8')));
} else {
res.end(TEXT_RESPONSE);
}
});
server.listen(PORT);
});

afterEach(
async () =>
new Promise((resolveClose) => {
server.close(resolveClose);
}),
);
afterEach(
async () =>
new Promise((resolveClose) => {
server.close(resolveClose);
}),
);

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

it('can make a basic post ignoring compressBodyIfPossible', async () => {
await requests.fetch(`http://localhost:${PORT}`, {
method: 'POST',
body: 'BODY TEXT',
compressBodyIfPossible: true,
});
const serverResult = await promise;
expect(serverResult.method).toEqual('POST');
expect(serverResult.body).toEqual('BODY TEXT');
});

it('can make a request with headers', async () => {
await requests.fetch(`http://localhost:${PORT}`, {
method: 'POST',
Expand Down Expand Up @@ -166,3 +180,30 @@ describe('given a default instance of NodeRequests', () => {
expect(serverResult.body).toEqual('');
});
});

describe('given an instance of NodeRequests with enableEventCompression turned on', () => {
const requests = new NodeRequests(undefined, undefined, undefined, true);
it('can make a basic post with compressBodyIfPossible enabled', async () => {
await requests.fetch(`http://localhost:${PORT}`, {
method: 'POST',
body: 'BODY TEXT',
compressBodyIfPossible: true,
});
const serverResult = await promise;
expect(serverResult.method).toEqual('POST');
expect(serverResult.headers['content-encoding']).toEqual('gzip');
expect(serverResult.body).toEqual(zlib.gzipSync('BODY TEXT'));
});

it('can make a basic post with compressBodyIfPossible disabled', async () => {
await requests.fetch(`http://localhost:${PORT}`, {
method: 'POST',
body: 'BODY TEXT',
compressBodyIfPossible: false,
});
const serverResult = await promise;
expect(serverResult.method).toEqual('POST');
expect(serverResult.headers['content-encoding']).toBeUndefined();
expect(serverResult.body).toEqual('BODY TEXT');
});
});
7 changes: 6 additions & 1 deletion packages/sdk/server-node/src/platform/NodePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export default class NodePlatform implements platform.Platform {

constructor(options: LDOptions) {
this.info = new NodeInfo(options);
this.requests = new NodeRequests(options.tlsParams, options.proxyOptions, options.logger);
this.requests = new NodeRequests(
options.tlsParams,
options.proxyOptions,
options.logger,
options.enableEventCompression,
);
}
}
45 changes: 34 additions & 11 deletions packages/sdk/server-node/src/platform/NodeRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent';
// No types for the event source.
// @ts-ignore
import { EventSource as LDEventSource } from 'launchdarkly-eventsource';
import { promisify } from 'util';
import * as zlib from 'zlib';

import {
EventSourceCapabilities,
Expand All @@ -16,6 +18,8 @@ import {

import NodeResponse from './NodeResponse';

const gzip = promisify(zlib.gzip);

function processTlsOptions(tlsOptions: LDTLSOptions): https.AgentOptions {
const options: https.AgentOptions & { [index: string]: any } = {
ca: tlsOptions.ca,
Expand Down Expand Up @@ -101,25 +105,44 @@ export default class NodeRequests implements platform.Requests {

private _hasProxyAuth: boolean = false;

constructor(tlsOptions?: LDTLSOptions, proxyOptions?: LDProxyOptions, logger?: LDLogger) {
private _enableBodyCompression: boolean = false;

constructor(
tlsOptions?: LDTLSOptions,
proxyOptions?: LDProxyOptions,
logger?: LDLogger,
enableEventCompression?: boolean,
) {
this._agent = createAgent(tlsOptions, proxyOptions, logger);
this._hasProxy = !!proxyOptions;
this._hasProxyAuth = !!proxyOptions?.auth;
this._enableBodyCompression = !!enableEventCompression;
}

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

const headers = { ...options.headers };
let bodyData: String | Buffer | undefined = options.body;

// For get requests we are going to automatically support compressed responses.
// Note this does not affect SSE as the event source is not using this fetch implementation.
const headers =
options.method?.toLowerCase() === 'get'
? {
...options.headers,
'accept-encoding': 'gzip',
}
: options.headers;
if (options.method?.toLowerCase() === 'get') {
headers['accept-encoding'] = 'gzip';
}
// For post requests we are going to support compressed post bodies if the
// enableEventCompression config setting is true and the compressBodyIfPossible
// option is true.
else if (
this._enableBodyCompression &&
!!options.compressBodyIfPossible &&
options.method?.toLowerCase() === 'post' &&
options.body
) {
headers['content-encoding'] = 'gzip';
bodyData = await gzip(Buffer.from(options.body, 'utf8'));
}

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

if (options.body) {
req.write(options.body);
if (bodyData) {
req.write(bodyData);
}

req.on('error', (err) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('given an event sender', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(`${basicConfig.serviceEndpoints.events}/bulk`, {
body: JSON.stringify(testEventData1),
compressBodyIfPossible: true,
headers: analyticsHeaders(uuid),
method: 'POST',
keepalive: true,
Expand All @@ -150,6 +151,7 @@ describe('given an event sender', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenNthCalledWith(1, `${basicConfig.serviceEndpoints.events}/bulk`, {
body: JSON.stringify(testEventData1),
compressBodyIfPossible: true,
headers: analyticsHeaders(uuid),
method: 'POST',
keepalive: true,
Expand All @@ -159,6 +161,7 @@ describe('given an event sender', () => {
`${basicConfig.serviceEndpoints.events}/diagnostic`,
{
body: JSON.stringify(testEventData2),
compressBodyIfPossible: true,
headers: diagnosticHeaders,
method: 'POST',
keepalive: true,
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/common/src/api/platform/Requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export interface Options {
headers?: Record<string, string>;
method?: string;
body?: string;
/**
* Gzip compress the post body only if the underlying SDK framework supports it
* and the config option enableEventCompression is set to true.
*/
compressBodyIfPossible?: boolean;
timeout?: number;
/**
* For use in browser environments. Platform support will be best effort for this field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default class EventSender implements LDEventSender {
const { status, headers: resHeaders } = await this._requests.fetch(uri, {
headers,
body: JSON.stringify(events),
compressBodyIfPossible: true,
method: 'POST',
// When sending events from browser environments the request should be completed even
// if the user is navigating away from the page.
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/sdk-server/src/api/options/LDOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,14 @@ export interface LDOptions {
* ```
*/
hooks?: Hook[];

/**
* Set to true to opt in to compressing event payloads if the SDK supports it, since the
* compression library may not be supported in the underlying SDK framework. If the compression
* library is not supported then event payloads will not be compressed even if this option
* is enabled.
*
* Defaults to false.
*/
enableEventCompression?: boolean;
}
5 changes: 5 additions & 0 deletions packages/shared/sdk-server/src/options/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const validations: Record<string, TypeValidator> = {
application: TypeValidators.Object,
payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
hooks: TypeValidators.createTypeArray('Hook[]', {}),
enableEventCompression: TypeValidators.Boolean,
};

/**
Expand All @@ -82,6 +83,7 @@ export const defaultValues: ValidatedOptions = {
diagnosticOptOut: false,
diagnosticRecordingInterval: 900,
featureStore: () => new InMemoryFeatureStore(),
enableEventCompression: false,
};

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

public readonly hooks?: Hook[];

public readonly enableEventCompression: boolean;

constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) {
// The default will handle undefined, but not null.
// Because we can be called from JS we need to be extra defensive.
Expand Down Expand Up @@ -283,5 +287,6 @@ export default class Configuration {
}

this.hooks = validatedOptions.hooks;
this.enableEventCompression = validatedOptions.enableEventCompression;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ export interface ValidatedOptions {
[index: string]: any;
bigSegments?: LDBigSegmentsOptions;
hooks?: Hook[];
enableEventCompression: boolean;
}
Loading