Skip to content

Commit 84830ad

Browse files
authored
2.0.0 (#89)
1 parent e190be9 commit 84830ad

22 files changed

+1624
-485
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@
33
All notable changes to the LaunchDarkly client-side JavaScript SDK will be documented in this file.
44
This project adheres to [Semantic Versioning](http://semver.org).
55

6+
## [2.0.0] - 2018-05-25
7+
### Changed
8+
- To reduce the network bandwidth used for analytics events, feature request events are now sent as counters rather than individual events, and user details are now sent only at intervals rather than in each event. These behaviors can be modified through the LaunchDarkly UI and with the new configuration option `inlineUsersInEvents`. For more details, see [Analytics Data Stream Reference](https://docs.launchdarkly.com/v2.0/docs/analytics-data-stream-reference).
9+
- In every function that takes an optional callback parameter, if you provide a callback, the function will not return a promise; a promise will be returned only if you omit the callback. Previously, it would always return a promise which would be resolved/rejected at the same time that the callback (if any) was called; this caused problems if you had not registered an error handler for the promise.
10+
- When sending analytics events, if there is a connection error or an HTTP 5xx response, the client will try to send the events again one more time after a one-second delay.
11+
- Analytics are now sent with an HTTP `POST` request if the browser supports CORS, or via image loading if it does not. Previously, they were always sent via image loading.
12+
13+
### Added
14+
- The new configuration option `sendEventsOnlyForVariation`, if set to `true`, causes analytics events for feature flags to be sent only when you call `variation`. Otherwise, the default behavior is to also send events when you call `allFlags`, and whenever a changed flag value is detected in streaming mode.
15+
- The new configuration option `allowFrequentDuplicateEvents`, if set to `true`, turns off throttling for feature flag events. Otherwise, the default behavior is to block the sending of an analytics event if another event with the same flag key, flag value, and user key was generated within the last five minutes.
16+
17+
### Fixed
18+
- If `identify` is called with a null user, or a user with no key, the function no longer tries to do an HTTP request to the server (which would always fail); instead, it just returns an error.
19+
20+
### Deprecated
21+
- The configuration options `all_attributes_private` and `private_attribute_names` are deprecated. Use `allAttributesPrivate` and `privateAttributeNames` instead.
22+
623
## [1.7.4] - 2018-05-23
724
### Fixed
825
- Fixed a bug that caused events _not_ to be sent if `options.sendEvents` was explicitly set to `true`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ldclient-js",
3-
"version": "1.7.4",
3+
"version": "2.0.0",
44
"description": "LaunchDarkly SDK for JavaScript",
55
"author": "LaunchDarkly <[email protected]>",
66
"license": "Apache-2.0",

src/EventProcessor.js

Lines changed: 125 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,149 @@
1+
import EventSender from './EventSender';
2+
import EventSummarizer from './EventSummarizer';
3+
import UserFilter from './UserFilter';
4+
import * as errors from './errors';
15
import * as utils from './utils';
26

3-
const MAX_URL_LENGTH = 2000;
7+
export default function EventProcessor(eventsUrl, environmentId, options = {}, emitter = null, sender = null) {
8+
const processor = {};
9+
const eventSender = sender || EventSender(eventsUrl, environmentId);
10+
const summarizer = EventSummarizer();
11+
const userFilter = UserFilter(options);
12+
const inlineUsers = !!options.inlineUsersInEvents;
13+
let queue = [];
14+
let flushInterval;
15+
let samplingInterval;
16+
let lastKnownPastTime = 0;
17+
let disabled = false;
18+
let flushTimer;
19+
20+
function reportArgumentError(message) {
21+
utils.onNextTick(() => {
22+
emitter && emitter.maybeReportError(new errors.LDInvalidArgumentError(message));
23+
});
24+
}
425

5-
function sendEvents(eventsUrl, events, sync) {
6-
const src = eventsUrl + '?d=' + utils.base64URLEncode(JSON.stringify(events));
26+
if (options.samplingInterval !== undefined && (isNaN(options.samplingInterval) || options.samplingInterval < 0)) {
27+
samplingInterval = 0;
28+
reportArgumentError('Invalid sampling interval configured. Sampling interval must be an integer >= 0.');
29+
} else {
30+
samplingInterval = options.samplingInterval || 0;
31+
}
732

8-
const send = onDone => {
9-
const xhr = new XMLHttpRequest();
10-
const hasCors = 'withCredentials' in xhr;
33+
if (options.flushInterval !== undefined && (isNan(options.flushInterval) || options.flushInterval < 2000)) {
34+
flushInterval = 2000;
35+
reportArgumentError('Invalid flush interval configured. Must be an integer >= 2000 (milliseconds).');
36+
} else {
37+
flushInterval = options.flushInterval || 2000;
38+
}
1139

12-
// Detect browser support for CORS
13-
if (hasCors) {
14-
/* supports cross-domain requests */
15-
xhr.open('GET', src, !sync);
40+
function shouldSampleEvent() {
41+
return samplingInterval === 0 || Math.floor(Math.random() * samplingInterval) === 0;
42+
}
1643

17-
if (!sync) {
18-
xhr.addEventListener('load', onDone);
19-
}
44+
function shouldDebugEvent(e) {
45+
if (e.debugEventsUntilDate) {
46+
// The "last known past time" comes from the last HTTP response we got from the server.
47+
// In case the client's time is set wrong, at least we know that any expiration date
48+
// earlier than that point is definitely in the past. If there's any discrepancy, we
49+
// want to err on the side of cutting off event debugging sooner.
50+
return e.debugEventsUntilDate > lastKnownPastTime && e.debugEventsUntilDate > new Date().getTime();
51+
}
52+
return false;
53+
}
2054

21-
xhr.send();
55+
// Transform an event from its internal format to the format we use when sending a payload.
56+
function makeOutputEvent(e) {
57+
const ret = Object.assign({}, e);
58+
if (inlineUsers || e.kind === 'identify') {
59+
// identify events always have an inline user
60+
ret.user = userFilter.filterUser(e.user);
2261
} else {
23-
const img = new Image();
24-
25-
if (!sync) {
26-
img.addEventListener('load', onDone);
27-
}
28-
29-
img.src = src;
62+
ret.userKey = e.user.key;
63+
delete ret['user'];
3064
}
31-
};
32-
33-
if (sync) {
34-
send();
35-
} else {
36-
return new Promise(resolve => {
37-
send(resolve);
38-
});
65+
if (e.kind === 'feature') {
66+
delete ret['trackEvents'];
67+
delete ret['debugEventsUntilDate'];
68+
}
69+
return ret;
3970
}
40-
}
41-
42-
export default function EventProcessor(eventsUrl, eventSerializer) {
43-
const processor = {};
44-
let queue = [];
45-
let initialFlush = true;
4671

4772
processor.enqueue = function(event) {
48-
queue.push(event);
49-
};
50-
51-
processor.flush = function(user, sync) {
52-
const finalSync = sync === undefined ? false : sync;
53-
const serializedQueue = eventSerializer.serializeEvents(queue);
54-
55-
if (!user) {
56-
if (initialFlush) {
57-
if (console && console.warn) {
58-
console.warn(
59-
'Be sure to call `identify` in the LaunchDarkly client: http://docs.launchdarkly.com/docs/running-an-ab-test#include-the-client-side-snippet'
60-
);
61-
}
73+
if (disabled) {
74+
return;
75+
}
76+
let addFullEvent = false;
77+
let addDebugEvent = false;
78+
79+
// Add event to the summary counters if appropriate
80+
summarizer.summarizeEvent(event);
81+
82+
// Decide whether to add the event to the payload. Feature events may be added twice, once for
83+
// the event (if tracked) and once for debugging.
84+
if (event.kind === 'feature') {
85+
if (shouldSampleEvent()) {
86+
addFullEvent = !!event.trackEvents;
87+
addDebugEvent = shouldDebugEvent(event);
6288
}
63-
return Promise.resolve();
89+
} else {
90+
addFullEvent = shouldSampleEvent();
6491
}
6592

66-
initialFlush = false;
93+
if (addFullEvent) {
94+
queue.push(makeOutputEvent(event));
95+
}
96+
if (addDebugEvent) {
97+
const debugEvent = Object.assign({}, event, { kind: 'debug' });
98+
delete debugEvent['trackEvents'];
99+
delete debugEvent['debugEventsUntilDate'];
100+
delete debugEvent['variation'];
101+
queue.push(debugEvent);
102+
}
103+
};
67104

68-
if (serializedQueue.length === 0) {
105+
processor.flush = function(sync) {
106+
if (disabled) {
69107
return Promise.resolve();
70108
}
71-
72-
const chunks = utils.chunkUserEventsForUrl(MAX_URL_LENGTH - eventsUrl.length, serializedQueue);
73-
74-
const results = [];
75-
for (let i = 0; i < chunks.length; i++) {
76-
results.push(sendEvents(eventsUrl, chunks[i], finalSync));
109+
const eventsToSend = queue;
110+
const summary = summarizer.getSummary();
111+
summarizer.clearSummary();
112+
if (summary) {
113+
summary.kind = 'summary';
114+
eventsToSend.push(summary);
115+
}
116+
if (eventsToSend.length === 0) {
117+
return Promise.resolve();
77118
}
78-
79119
queue = [];
120+
return eventSender.sendEvents(eventsToSend, sync).then(responseInfo => {
121+
if (responseInfo) {
122+
if (responseInfo.serverTime) {
123+
lastKnownPastTime = responseInfo.serverTime;
124+
}
125+
if (responseInfo.status === 401) {
126+
disabled = true;
127+
utils.onNextTick(() => {
128+
emitter.maybeReportError(
129+
new errors.LDUnexpectedResponseError('Received 401 error, no further events will be posted')
130+
);
131+
});
132+
}
133+
}
134+
});
135+
};
136+
137+
processor.start = function() {
138+
const flushTick = () => {
139+
processor.flush();
140+
flushTimer = setTimeout(flushTick, flushInterval);
141+
};
142+
flushTimer = setTimeout(flushTick, flushInterval);
143+
};
80144

81-
return sync ? Promise.resolve() : Promise.all(results);
145+
processor.stop = function() {
146+
clearTimeout(flushTimer);
82147
};
83148

84149
return processor;

src/EventSender.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as utils from './utils';
2+
3+
const MAX_URL_LENGTH = 2000;
4+
5+
export default function EventSender(eventsUrl, environmentId, forceHasCors, imageCreator) {
6+
let hasCors;
7+
const postUrl = eventsUrl + '/events/bulk/' + environmentId;
8+
const imageUrl = eventsUrl + '/a/' + environmentId + '.gif';
9+
const sender = {};
10+
11+
function loadUrlUsingImage(src, onDone) {
12+
const img = new Image();
13+
if (onDone) {
14+
img.addEventListener('load', onDone);
15+
}
16+
img.src = src;
17+
}
18+
19+
function getResponseInfo(xhr) {
20+
const ret = { status: xhr.status };
21+
const dateStr = xhr.getResponseHeader('Date');
22+
if (dateStr) {
23+
const time = Date.parse(dateStr);
24+
if (time) {
25+
ret.serverTime = time;
26+
}
27+
}
28+
return ret;
29+
}
30+
31+
function sendChunk(events, usePost, sync) {
32+
const createImage = imageCreator || loadUrlUsingImage;
33+
const send = onDone => {
34+
if (usePost) {
35+
const xhr = new XMLHttpRequest();
36+
xhr.open('POST', postUrl, !sync);
37+
xhr.setRequestHeader('Content-Type', 'application/json');
38+
39+
if (!sync) {
40+
xhr.addEventListener('load', () => {
41+
onDone(getResponseInfo(xhr));
42+
});
43+
}
44+
45+
xhr.send(JSON.stringify(events));
46+
} else {
47+
const src = imageUrl + '?d=' + utils.base64URLEncode(JSON.stringify(events));
48+
createImage(src, sync ? null : onDone);
49+
}
50+
};
51+
52+
if (sync) {
53+
send();
54+
} else {
55+
return new Promise(resolve => {
56+
send(resolve);
57+
});
58+
}
59+
}
60+
61+
sender.sendEvents = function(events, sync) {
62+
// Detect browser support for CORS (can be overridden by tests)
63+
if (hasCors === undefined) {
64+
if (forceHasCors === undefined) {
65+
hasCors = 'withCredentials' in new XMLHttpRequest();
66+
} else {
67+
hasCors = forceHasCors;
68+
}
69+
}
70+
71+
const finalSync = sync === undefined ? false : sync;
72+
let chunks;
73+
if (hasCors) {
74+
// no need to break up events into chunks if we can send a POST
75+
chunks = [events];
76+
} else {
77+
chunks = utils.chunkUserEventsForUrl(MAX_URL_LENGTH - eventsUrl.length, events);
78+
}
79+
const results = [];
80+
for (let i = 0; i < chunks.length; i++) {
81+
results.push(sendChunk(chunks[i], hasCors, finalSync));
82+
}
83+
return sync ? Promise.resolve() : Promise.all(results);
84+
};
85+
86+
return sender;
87+
}

0 commit comments

Comments
 (0)