launchdarkly-js-sdk-common 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/CODEOWNERS +2 -0
- package/README.md +2 -2
- package/SECURITY.md +5 -0
- package/package.json +2 -1
- package/src/ContextFilter.js +15 -10
- package/src/EventProcessor.js +4 -2
- package/src/EventSender.js +6 -30
- package/src/__tests__/ContextFilter-test.js +33 -0
- package/src/__tests__/EventProcessor-test.js +4 -4
- package/src/__tests__/EventSender-test.js +14 -70
- package/src/__tests__/stubPlatform.js +0 -4
- package/src/__tests__/testUtils.js +1 -1
- package/src/__tests__/utils-test.js +1 -12
- package/src/utils.js +0 -41
- package/typings.d.ts +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org).
|
|
4
4
|
|
|
5
|
+
## [5.0.3] - 2023-03-21
|
|
6
|
+
### Changed:
|
|
7
|
+
- Update `LDContext` to allow for key to be optional. This is used when making an anonymous context with a generated key.
|
|
8
|
+
|
|
9
|
+
## [5.0.2] - 2023-02-15
|
|
10
|
+
### Changed:
|
|
11
|
+
- Removed usage of optional chaining (`?.`) to improve compatibility with projects which are using older transpilation tooling.
|
|
12
|
+
|
|
5
13
|
## [5.0.1] - 2023-01-10
|
|
6
14
|
### Changed:
|
|
7
15
|
- Updated all types in `typings.d.ts` to be exported. This is to ensure that those types are included in generated documentation of dependent SDKs.
|
package/CODEOWNERS
ADDED
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## LaunchDarkly overview
|
|
6
6
|
|
|
7
|
-
[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves
|
|
7
|
+
[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today!
|
|
8
8
|
|
|
9
9
|
[](https://twitter.com/intent/follow?screen_name=launchdarkly)
|
|
10
10
|
|
|
@@ -27,7 +27,7 @@ We encourage pull requests and other contributions from the community. Check out
|
|
|
27
27
|
* Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
|
|
28
28
|
* Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
|
|
29
29
|
* Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
|
|
30
|
-
* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies.
|
|
30
|
+
* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list.
|
|
31
31
|
* Explore LaunchDarkly
|
|
32
32
|
* [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
|
|
33
33
|
* [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Reporting and Fixing Security Issues
|
|
2
|
+
|
|
3
|
+
Please report all security issues to the LaunchDarkly security team by submitting a bug bounty report to our [HackerOne program](https://hackerone.com/launchdarkly?type=team). LaunchDarkly will triage and address all valid security issues following the response targets defined in our program policy. Valid security issues may be eligible for a bounty.
|
|
4
|
+
|
|
5
|
+
Please do not open issues or pull requests for security issues. This makes the problem immediately visible to everyone, including potentially malicious actors.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchdarkly-js-sdk-common",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "LaunchDarkly SDK for JavaScript - common code",
|
|
5
5
|
"author": "LaunchDarkly <team@launchdarkly.com>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@babel/runtime": "7.6.3",
|
|
29
29
|
"@rollup/plugin-replace": "^2.2.0",
|
|
30
30
|
"@types/jest": "^27.4.1",
|
|
31
|
+
"@types/node": "12.12.6",
|
|
31
32
|
"babel-eslint": "^10.1.0",
|
|
32
33
|
"babel-jest": "^25.1.0",
|
|
33
34
|
"cross-env": "^5.1.4",
|
package/src/ContextFilter.js
CHANGED
|
@@ -16,23 +16,27 @@ function ContextFilter(config) {
|
|
|
16
16
|
* @param {Object} context
|
|
17
17
|
* @returns {string[]} A list of the attributes to filter.
|
|
18
18
|
*/
|
|
19
|
-
const getAttributesToFilter = context =>
|
|
20
|
-
(allAttributesPrivate
|
|
19
|
+
const getAttributesToFilter = (context, redactAnonymous) =>
|
|
20
|
+
(allAttributesPrivate || (redactAnonymous && context.anonymous)
|
|
21
21
|
? Object.keys(context)
|
|
22
22
|
: [...privateAttributes, ...((context._meta && context._meta.privateAttributes) || [])]
|
|
23
23
|
).filter(attr => !protectedAttributes.some(protectedAttr => AttributeReference.compare(attr, protectedAttr)));
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @param {Object} context
|
|
27
|
+
* @param {boolean} redactAnonymous
|
|
27
28
|
* @returns {Object} A copy of the context with private attributes removed,
|
|
28
29
|
* and the redactedAttributes meta populated.
|
|
29
30
|
*/
|
|
30
|
-
const filterSingleKind = context => {
|
|
31
|
+
const filterSingleKind = (context, redactAnonymous) => {
|
|
31
32
|
if (typeof context !== 'object' || context === null || Array.isArray(context)) {
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const { cloned, excluded } = AttributeReference.cloneExcluding(
|
|
36
|
+
const { cloned, excluded } = AttributeReference.cloneExcluding(
|
|
37
|
+
context,
|
|
38
|
+
getAttributesToFilter(context, redactAnonymous)
|
|
39
|
+
);
|
|
36
40
|
cloned.key = String(cloned.key);
|
|
37
41
|
if (excluded.length) {
|
|
38
42
|
if (!cloned._meta) {
|
|
@@ -57,10 +61,11 @@ function ContextFilter(config) {
|
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
63
|
* @param {Object} context
|
|
64
|
+
* @param {boolean} redactAnonymous
|
|
60
65
|
* @returns {Object} A copy of the context with the private attributes removed,
|
|
61
66
|
* and the redactedAttributes meta populated for each sub-context.
|
|
62
67
|
*/
|
|
63
|
-
const filterMultiKind = context => {
|
|
68
|
+
const filterMultiKind = (context, redactAnonymous) => {
|
|
64
69
|
const filtered = {
|
|
65
70
|
kind: context.kind,
|
|
66
71
|
};
|
|
@@ -68,7 +73,7 @@ function ContextFilter(config) {
|
|
|
68
73
|
|
|
69
74
|
for (const contextKey of contextKeys) {
|
|
70
75
|
if (contextKey !== 'kind') {
|
|
71
|
-
const filteredContext = filterSingleKind(context[contextKey]);
|
|
76
|
+
const filteredContext = filterSingleKind(context[contextKey], redactAnonymous);
|
|
72
77
|
if (filteredContext) {
|
|
73
78
|
filtered[contextKey] = filteredContext;
|
|
74
79
|
}
|
|
@@ -121,13 +126,13 @@ function ContextFilter(config) {
|
|
|
121
126
|
return filtered;
|
|
122
127
|
};
|
|
123
128
|
|
|
124
|
-
filter.filter = context => {
|
|
129
|
+
filter.filter = (context, redactAnonymous = false) => {
|
|
125
130
|
if (context.kind === undefined || context.kind === null) {
|
|
126
|
-
return filterSingleKind(legacyToSingleKind(context));
|
|
131
|
+
return filterSingleKind(legacyToSingleKind(context), redactAnonymous);
|
|
127
132
|
} else if (context.kind === 'multi') {
|
|
128
|
-
return filterMultiKind(context);
|
|
133
|
+
return filterMultiKind(context, redactAnonymous);
|
|
129
134
|
} else {
|
|
130
|
-
return filterSingleKind(context);
|
|
135
|
+
return filterSingleKind(context, redactAnonymous);
|
|
131
136
|
}
|
|
132
137
|
};
|
|
133
138
|
|
package/src/EventProcessor.js
CHANGED
|
@@ -50,6 +50,9 @@ function EventProcessor(
|
|
|
50
50
|
if (e.kind === 'identify') {
|
|
51
51
|
// identify events always have an inline context
|
|
52
52
|
ret.context = contextFilter.filter(e.context);
|
|
53
|
+
} else if (e.kind === 'feature') {
|
|
54
|
+
// feature events always have an inline context
|
|
55
|
+
ret.context = contextFilter.filter(e.context, true);
|
|
53
56
|
} else {
|
|
54
57
|
ret.contextKeys = getContextKeysFromEvent(e);
|
|
55
58
|
delete ret['context'];
|
|
@@ -136,8 +139,7 @@ function EventProcessor(
|
|
|
136
139
|
}
|
|
137
140
|
queue = [];
|
|
138
141
|
logger.debug(messages.debugPostingEvents(eventsToSend.length));
|
|
139
|
-
return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(
|
|
140
|
-
const responseInfo = responses && responses[0];
|
|
142
|
+
return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(responseInfo => {
|
|
141
143
|
if (responseInfo) {
|
|
142
144
|
if (responseInfo.serverTime) {
|
|
143
145
|
lastKnownPastTime = responseInfo.serverTime;
|
package/src/EventSender.js
CHANGED
|
@@ -3,12 +3,8 @@ const utils = require('./utils');
|
|
|
3
3
|
const { v1: uuidv1 } = require('uuid');
|
|
4
4
|
const { getLDHeaders, transformHeaders } = require('./headers');
|
|
5
5
|
|
|
6
|
-
const MAX_URL_LENGTH = 2000;
|
|
7
|
-
|
|
8
6
|
function EventSender(platform, environmentId, options) {
|
|
9
|
-
const imageUrlPath = '/a/' + environmentId + '.gif';
|
|
10
7
|
const baseHeaders = utils.extend({ 'Content-Type': 'application/json' }, getLDHeaders(platform, options));
|
|
11
|
-
const httpFallbackPing = platform.httpFallbackPing; // this will be set for us if we're in the browser SDK
|
|
12
8
|
const sender = {};
|
|
13
9
|
|
|
14
10
|
function getResponseInfo(result) {
|
|
@@ -23,7 +19,11 @@ function EventSender(platform, environmentId, options) {
|
|
|
23
19
|
return ret;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
sender.
|
|
22
|
+
sender.sendEvents = (events, url, isDiagnostic) => {
|
|
23
|
+
if (!platform.httpRequest) {
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
27
|
const jsonBody = JSON.stringify(events);
|
|
28
28
|
const payloadId = isDiagnostic ? null : uuidv1();
|
|
29
29
|
|
|
@@ -55,31 +55,7 @@ function EventSender(platform, environmentId, options) {
|
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
return doPostRequest(true).catch(() => {});
|
|
60
|
-
} else {
|
|
61
|
-
httpFallbackPing && httpFallbackPing(url + imageUrlPath + '?d=' + utils.base64URLEncode(jsonBody));
|
|
62
|
-
return Promise.resolve(); // we don't wait for this request to complete, it's just a one-way ping
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
sender.sendEvents = function(events, url, isDiagnostic) {
|
|
67
|
-
if (!platform.httpRequest) {
|
|
68
|
-
return Promise.resolve();
|
|
69
|
-
}
|
|
70
|
-
const canPost = platform.httpAllowsPost();
|
|
71
|
-
let chunks;
|
|
72
|
-
if (canPost) {
|
|
73
|
-
// no need to break up events into chunks if we can send a POST
|
|
74
|
-
chunks = [events];
|
|
75
|
-
} else {
|
|
76
|
-
chunks = utils.chunkEventsForUrl(MAX_URL_LENGTH - url.length, events);
|
|
77
|
-
}
|
|
78
|
-
const results = [];
|
|
79
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
80
|
-
results.push(sender.sendChunk(chunks[i], url, isDiagnostic, canPost));
|
|
81
|
-
}
|
|
82
|
-
return Promise.all(results);
|
|
58
|
+
return doPostRequest(true).catch(() => {});
|
|
83
59
|
};
|
|
84
60
|
|
|
85
61
|
return sender;
|
|
@@ -283,6 +283,11 @@ describe('when handling single kind contexts', () => {
|
|
|
283
283
|
expect(uf.filter(anonymousContext)).toEqual(contextWithAllAttrsHidden);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
it('all attributes are redacted when anonymous', () => {
|
|
287
|
+
const uf = ContextFilter({});
|
|
288
|
+
expect(uf.filter(anonymousContext, true)).toEqual(contextWithAllAttrsHidden);
|
|
289
|
+
});
|
|
290
|
+
|
|
286
291
|
it('converts non-boolean anonymous to boolean.', () => {
|
|
287
292
|
const uf = ContextFilter({});
|
|
288
293
|
expect(uf.filter({ kind: 'user', key: 'user', anonymous: 'string' })).toEqual({
|
|
@@ -330,6 +335,7 @@ describe('when handling mult-kind contexts', () => {
|
|
|
330
335
|
user: {
|
|
331
336
|
key: 'abc',
|
|
332
337
|
name: 'alphabet',
|
|
338
|
+
anonymous: true,
|
|
333
339
|
letters: ['a', 'b', 'c'],
|
|
334
340
|
order: 3,
|
|
335
341
|
object: {
|
|
@@ -342,6 +348,25 @@ describe('when handling mult-kind contexts', () => {
|
|
|
342
348
|
},
|
|
343
349
|
};
|
|
344
350
|
|
|
351
|
+
const orgAndUserContextWithAnonymousRedaction = {
|
|
352
|
+
kind: 'multi',
|
|
353
|
+
organization: {
|
|
354
|
+
key: 'LD',
|
|
355
|
+
rocks: true,
|
|
356
|
+
name: 'name',
|
|
357
|
+
department: {
|
|
358
|
+
name: 'sdk',
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
user: {
|
|
362
|
+
key: 'abc',
|
|
363
|
+
anonymous: true,
|
|
364
|
+
_meta: {
|
|
365
|
+
redactedAttributes: ['/letters', '/name', '/object', '/order'],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
345
370
|
const orgAndUserContextAllPrivate = {
|
|
346
371
|
kind: 'multi',
|
|
347
372
|
organization: {
|
|
@@ -352,6 +377,7 @@ describe('when handling mult-kind contexts', () => {
|
|
|
352
377
|
},
|
|
353
378
|
user: {
|
|
354
379
|
key: 'abc',
|
|
380
|
+
anonymous: true,
|
|
355
381
|
_meta: {
|
|
356
382
|
redactedAttributes: ['/letters', '/name', '/object', '/order'],
|
|
357
383
|
},
|
|
@@ -373,6 +399,7 @@ describe('when handling mult-kind contexts', () => {
|
|
|
373
399
|
user: {
|
|
374
400
|
key: 'abc',
|
|
375
401
|
order: 3,
|
|
402
|
+
anonymous: true,
|
|
376
403
|
object: {
|
|
377
404
|
a: 'a',
|
|
378
405
|
},
|
|
@@ -395,6 +422,7 @@ describe('when handling mult-kind contexts', () => {
|
|
|
395
422
|
user: {
|
|
396
423
|
key: 'abc',
|
|
397
424
|
name: 'alphabet',
|
|
425
|
+
anonymous: true,
|
|
398
426
|
order: 3,
|
|
399
427
|
object: {
|
|
400
428
|
a: 'a',
|
|
@@ -415,6 +443,11 @@ describe('when handling mult-kind contexts', () => {
|
|
|
415
443
|
expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserContextAllPrivate);
|
|
416
444
|
});
|
|
417
445
|
|
|
446
|
+
it('it should remove attributes from all anonymous contexts', () => {
|
|
447
|
+
const uf = ContextFilter({});
|
|
448
|
+
expect(uf.filter(orgAndUserContext, true)).toEqual(orgAndUserContextWithAnonymousRedaction);
|
|
449
|
+
});
|
|
450
|
+
|
|
418
451
|
it('it should apply private attributes from the context to the context.', () => {
|
|
419
452
|
const uf = ContextFilter({});
|
|
420
453
|
expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserContextIncludedPrivate);
|
|
@@ -75,7 +75,7 @@ describe.each([
|
|
|
75
75
|
expect(e.value).toEqual(source.value);
|
|
76
76
|
expect(e.default).toEqual(source.default);
|
|
77
77
|
expect(e.reason).toEqual(source.reason);
|
|
78
|
-
|
|
78
|
+
expect(e.context).toEqual(inlineUser);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
function checkCustomEvent(e, source) {
|
|
@@ -136,7 +136,7 @@ describe.each([
|
|
|
136
136
|
expect(mockEventSender.calls.length()).toEqual(1);
|
|
137
137
|
const output = (await mockEventSender.calls.take()).events;
|
|
138
138
|
expect(output.length).toEqual(2);
|
|
139
|
-
checkFeatureEvent(output[0], event, false);
|
|
139
|
+
checkFeatureEvent(output[0], event, false, eventContext);
|
|
140
140
|
checkSummaryEvent(output[1]);
|
|
141
141
|
});
|
|
142
142
|
});
|
|
@@ -159,7 +159,7 @@ describe.each([
|
|
|
159
159
|
expect(mockEventSender.calls.length()).toEqual(1);
|
|
160
160
|
const output = (await mockEventSender.calls.take()).events;
|
|
161
161
|
expect(output.length).toEqual(2);
|
|
162
|
-
checkFeatureEvent(output[0], event, false);
|
|
162
|
+
checkFeatureEvent(output[0], event, false, eventContext);
|
|
163
163
|
checkSummaryEvent(output[1]);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
@@ -236,7 +236,7 @@ describe.each([
|
|
|
236
236
|
expect(mockEventSender.calls.length()).toEqual(1);
|
|
237
237
|
const output = (await mockEventSender.calls.take()).events;
|
|
238
238
|
expect(output.length).toEqual(3);
|
|
239
|
-
checkFeatureEvent(output[0], e, false);
|
|
239
|
+
checkFeatureEvent(output[0], e, false, { ...context, kind: context.kind || 'user' });
|
|
240
240
|
checkFeatureEvent(output[1], e, true, { ...context, kind: context.kind || 'user' });
|
|
241
241
|
checkSummaryEvent(output[2]);
|
|
242
242
|
});
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import EventSender from '../EventSender';
|
|
2
2
|
import * as utils from '../utils';
|
|
3
3
|
|
|
4
|
-
import * as base64 from 'base64-js';
|
|
5
|
-
|
|
6
4
|
import { respond, networkError } from './mockHttp';
|
|
7
5
|
import * as stubPlatform from './stubPlatform';
|
|
8
6
|
|
|
@@ -20,74 +18,6 @@ describe('EventSender', () => {
|
|
|
20
18
|
platform = stubPlatform.defaults();
|
|
21
19
|
});
|
|
22
20
|
|
|
23
|
-
function fakeImageCreator() {
|
|
24
|
-
const ret = function(url) {
|
|
25
|
-
ret.urls.push(url);
|
|
26
|
-
};
|
|
27
|
-
ret.urls = [];
|
|
28
|
-
return ret;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function base64URLDecode(str) {
|
|
32
|
-
let s = str;
|
|
33
|
-
while (s.length % 4 !== 0) {
|
|
34
|
-
s = s + '=';
|
|
35
|
-
}
|
|
36
|
-
s = s.replace(/_/g, '/').replace(/-/g, '+');
|
|
37
|
-
const decodedBytes = base64.toByteArray(s);
|
|
38
|
-
const decodedStr = String.fromCharCode.apply(String, decodedBytes);
|
|
39
|
-
return decodeURIComponent(escape(decodedStr));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function decodeOutputFromUrl(url, baseUrl) {
|
|
43
|
-
const prefix = baseUrl + '/a/' + envId + '.gif?d=';
|
|
44
|
-
if (!url.startsWith(prefix)) {
|
|
45
|
-
throw 'URL "' + url + '" did not have expected prefix "' + prefix + '"';
|
|
46
|
-
}
|
|
47
|
-
return JSON.parse(base64URLDecode(url.substring(prefix.length)));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
describe('using image endpoint when CORS is not available', () => {
|
|
51
|
-
it('should encode events in a single chunk if they fit', async () => {
|
|
52
|
-
const server = platform.testing.http.newServer();
|
|
53
|
-
const imageCreator = fakeImageCreator();
|
|
54
|
-
const platformWithoutCors = { ...platform, httpAllowsPost: () => false, httpFallbackPing: imageCreator };
|
|
55
|
-
const sender = EventSender(platformWithoutCors, envId);
|
|
56
|
-
const event1 = { kind: 'identify', key: 'userKey1' };
|
|
57
|
-
const event2 = { kind: 'identify', key: 'userKey2' };
|
|
58
|
-
const events = [event1, event2];
|
|
59
|
-
|
|
60
|
-
await sender.sendEvents(events, server.url);
|
|
61
|
-
|
|
62
|
-
const urls = imageCreator.urls;
|
|
63
|
-
expect(urls.length).toEqual(1);
|
|
64
|
-
expect(decodeOutputFromUrl(urls[0], server.url)).toEqual(events);
|
|
65
|
-
|
|
66
|
-
expect(server.requests.length()).toEqual(0);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should send events in multiple chunks if necessary', async () => {
|
|
70
|
-
const server = platform.testing.http.newServer();
|
|
71
|
-
const imageCreator = fakeImageCreator();
|
|
72
|
-
const platformWithoutCors = { ...platform, httpAllowsPost: () => false, httpFallbackPing: imageCreator };
|
|
73
|
-
const sender = EventSender(platformWithoutCors, envId);
|
|
74
|
-
const events = [];
|
|
75
|
-
for (let i = 0; i < 80; i++) {
|
|
76
|
-
events.push({ kind: 'identify', key: 'thisIsALongUserKey' + i });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
await sender.sendEvents(events, server.url);
|
|
80
|
-
|
|
81
|
-
const urls = imageCreator.urls;
|
|
82
|
-
expect(urls.length).toEqual(3);
|
|
83
|
-
expect(decodeOutputFromUrl(urls[0], server.url)).toEqual(events.slice(0, 31));
|
|
84
|
-
expect(decodeOutputFromUrl(urls[1], server.url)).toEqual(events.slice(31, 61));
|
|
85
|
-
expect(decodeOutputFromUrl(urls[2], server.url)).toEqual(events.slice(61, 80));
|
|
86
|
-
|
|
87
|
-
expect(server.requests.length()).toEqual(0);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
21
|
describe('using POST when CORS is available', () => {
|
|
92
22
|
it('should send all events in request body', async () => {
|
|
93
23
|
const server = platform.testing.http.newServer();
|
|
@@ -239,6 +169,20 @@ describe('EventSender', () => {
|
|
|
239
169
|
});
|
|
240
170
|
});
|
|
241
171
|
|
|
172
|
+
describe('verify sendEvents response format', () => {
|
|
173
|
+
it('includes date header', async () => {
|
|
174
|
+
const options = { sendLDHeaders: true };
|
|
175
|
+
const server = platform.testing.http.newServer();
|
|
176
|
+
server.byDefault(respond(202, { date: 'Wed, 21 Oct 2015 07:28:00 GMT' }, '{}'));
|
|
177
|
+
|
|
178
|
+
const sender = EventSender(platform, envId, options);
|
|
179
|
+
const event = { kind: 'identify', key: 'userKey' };
|
|
180
|
+
const responseInfo = await sender.sendEvents([event], server.url);
|
|
181
|
+
|
|
182
|
+
expect(responseInfo.serverTime).toEqual(1445412480000);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
242
186
|
describe('When HTTP requests are not available at all', () => {
|
|
243
187
|
it('should silently discard events', async () => {
|
|
244
188
|
const server = platform.testing.http.newServer();
|
|
@@ -13,8 +13,6 @@ import { MockHttpState } from './mockHttp';
|
|
|
13
13
|
// httpRequest?: (method, url, headers, body, sync) => requestProperties
|
|
14
14
|
// requestProperties.promise: Promise // resolves to { status, header: (name) => value, body } or rejects for a network error
|
|
15
15
|
// requestProperties.cancel?: () => void // provided if it's possible to cancel requests in this implementation
|
|
16
|
-
// httpAllowsPost: boolean // true if we can do cross-origin POST requests
|
|
17
|
-
// httpFallbackPing?: (url) => {} // method for doing an HTTP GET without awaiting the result (i.e. browser image mechanism)
|
|
18
16
|
// getCurrentUrl: () => string // returns null if we're not in a browser
|
|
19
17
|
// isDoNotTrack: () => boolean
|
|
20
18
|
// localStorage: {
|
|
@@ -45,8 +43,6 @@ export function defaults() {
|
|
|
45
43
|
httpRequest: mockHttpState.doRequest,
|
|
46
44
|
diagnosticSdkData: { name: 'stub-sdk' },
|
|
47
45
|
diagnosticPlatformData: { name: 'stub-platform' },
|
|
48
|
-
httpAllowsPost: () => true,
|
|
49
|
-
httpAllowsSync: () => true,
|
|
50
46
|
getCurrentUrl: () => currentUrl,
|
|
51
47
|
isDoNotTrack: () => doNotTrack,
|
|
52
48
|
eventSourceFactory: (url, options) => {
|
|
@@ -62,7 +62,7 @@ export function MockEventSender() {
|
|
|
62
62
|
calls,
|
|
63
63
|
sendEvents: (events, url) => {
|
|
64
64
|
calls.add({ events, url });
|
|
65
|
-
return Promise.resolve(
|
|
65
|
+
return Promise.resolve({ serverTime, status });
|
|
66
66
|
},
|
|
67
67
|
setServerTime: time => {
|
|
68
68
|
serverTime = time;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendUrlPath,
|
|
1
|
+
import { appendUrlPath, getLDUserAgentString, wrapPromiseCallback } from '../utils';
|
|
2
2
|
|
|
3
3
|
import * as stubPlatform from './stubPlatform';
|
|
4
4
|
|
|
@@ -63,15 +63,4 @@ describe('utils', () => {
|
|
|
63
63
|
expect(ua).toEqual('stubClient/7.8.9');
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
-
|
|
67
|
-
describe('chunkEventsForUrl', () => {
|
|
68
|
-
it('should properly chunk the list of events', () => {
|
|
69
|
-
const context = { key: 'foo', kind: 'user' };
|
|
70
|
-
const event = { kind: 'identify', key: context.key };
|
|
71
|
-
const eventLength = base64URLEncode(JSON.stringify(event)).length;
|
|
72
|
-
const events = [event, event, event, event, event];
|
|
73
|
-
const chunks = chunkEventsForUrl(eventLength * 2, events);
|
|
74
|
-
expect(chunks).toEqual([[event, event], [event, event], [event]]);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
66
|
});
|
package/src/utils.js
CHANGED
|
@@ -112,46 +112,6 @@ function transformVersionedValuesToValues(flagsState) {
|
|
|
112
112
|
return ret;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* Returns an array of event groups each of which can be safely URL-encoded
|
|
117
|
-
* without hitting the safe maximum URL length of certain browsers.
|
|
118
|
-
*
|
|
119
|
-
* @param {number} maxLength maximum URL length targeted
|
|
120
|
-
* @param {Array[Object}]} events queue of events to divide
|
|
121
|
-
* @returns Array[Array[Object]]
|
|
122
|
-
*/
|
|
123
|
-
function chunkEventsForUrl(maxLength, events) {
|
|
124
|
-
const allEvents = events.slice(0);
|
|
125
|
-
const allChunks = [];
|
|
126
|
-
let remainingSpace = maxLength;
|
|
127
|
-
let chunk;
|
|
128
|
-
|
|
129
|
-
while (allEvents.length > 0) {
|
|
130
|
-
chunk = [];
|
|
131
|
-
|
|
132
|
-
while (remainingSpace > 0) {
|
|
133
|
-
const event = allEvents.shift();
|
|
134
|
-
if (!event) {
|
|
135
|
-
break;
|
|
136
|
-
}
|
|
137
|
-
remainingSpace = remainingSpace - base64URLEncode(JSON.stringify(event)).length;
|
|
138
|
-
// If we are over the max size, put this one back on the queue
|
|
139
|
-
// to try in the next round, unless this event alone is larger
|
|
140
|
-
// than the limit, in which case, screw it, and try it anyway.
|
|
141
|
-
if (remainingSpace < 0 && chunk.length > 0) {
|
|
142
|
-
allEvents.unshift(event);
|
|
143
|
-
} else {
|
|
144
|
-
chunk.push(event);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
remainingSpace = maxLength;
|
|
149
|
-
allChunks.push(chunk);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return allChunks;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
115
|
function getLDUserAgentString(platform) {
|
|
156
116
|
const version = platform.version || '?';
|
|
157
117
|
return platform.userAgent + '/' + version;
|
|
@@ -188,7 +148,6 @@ module.exports = {
|
|
|
188
148
|
appendUrlPath,
|
|
189
149
|
base64URLEncode,
|
|
190
150
|
btoa,
|
|
191
|
-
chunkEventsForUrl,
|
|
192
151
|
clone,
|
|
193
152
|
deepEquals,
|
|
194
153
|
extend,
|
package/typings.d.ts
CHANGED
|
@@ -345,8 +345,9 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
345
345
|
|
|
346
346
|
/**
|
|
347
347
|
* A unique string identifying a context.
|
|
348
|
+
* This value must be set unless the context is anonymous.
|
|
348
349
|
*/
|
|
349
|
-
key
|
|
350
|
+
key?: string;
|
|
350
351
|
|
|
351
352
|
/**
|
|
352
353
|
* The context's name.
|
|
@@ -847,14 +848,14 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
847
848
|
off(key: string, callback: (...args: any[]) => void, context?: any): void;
|
|
848
849
|
|
|
849
850
|
/**
|
|
850
|
-
* Track page events to use in goals or
|
|
851
|
+
* Track page events to use in metrics (goals) or Experimentation.
|
|
851
852
|
*
|
|
852
853
|
* LaunchDarkly automatically tracks pageviews and clicks that are specified in the
|
|
853
|
-
*
|
|
854
|
-
* events that do not currently have
|
|
854
|
+
* Metrics section of their dashboard. This can be used to track custom metrics or other
|
|
855
|
+
* events that do not currently have metrics.
|
|
855
856
|
*
|
|
856
857
|
* @param key
|
|
857
|
-
* The name of the event, which may correspond to a
|
|
858
|
+
* The name of the event, which may correspond to a metric in experiments.
|
|
858
859
|
* @param data
|
|
859
860
|
* Additional information to associate with the event.
|
|
860
861
|
* @param metricValue
|