|
| 1 | +import EventSender from './EventSender'; |
| 2 | +import EventSummarizer from './EventSummarizer'; |
| 3 | +import UserFilter from './UserFilter'; |
| 4 | +import * as errors from './errors'; |
1 | 5 | import * as utils from './utils';
|
2 | 6 |
|
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 | + } |
4 | 25 |
|
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 | + } |
7 | 32 |
|
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 | + } |
11 | 39 |
|
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 | + } |
16 | 43 |
|
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 | + } |
20 | 54 |
|
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); |
22 | 61 | } 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']; |
30 | 64 | }
|
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; |
39 | 70 | }
|
40 |
| -} |
41 |
| - |
42 |
| -export default function EventProcessor(eventsUrl, eventSerializer) { |
43 |
| - const processor = {}; |
44 |
| - let queue = []; |
45 |
| - let initialFlush = true; |
46 | 71 |
|
47 | 72 | 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); |
62 | 88 | }
|
63 |
| - return Promise.resolve(); |
| 89 | + } else { |
| 90 | + addFullEvent = shouldSampleEvent(); |
64 | 91 | }
|
65 | 92 |
|
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 | + }; |
67 | 104 |
|
68 |
| - if (serializedQueue.length === 0) { |
| 105 | + processor.flush = function(sync) { |
| 106 | + if (disabled) { |
69 | 107 | return Promise.resolve();
|
70 | 108 | }
|
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(); |
77 | 118 | }
|
78 |
| - |
79 | 119 | 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 | + }; |
80 | 144 |
|
81 |
| - return sync ? Promise.resolve() : Promise.all(results); |
| 145 | + processor.stop = function() { |
| 146 | + clearTimeout(flushTimer); |
82 | 147 | };
|
83 | 148 |
|
84 | 149 | return processor;
|
|
0 commit comments