mixpanel-browser 2.53.0 → 2.54.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixpanel-browser",
3
- "version": "2.53.0",
3
+ "version": "2.54.0",
4
4
  "description": "The official Mixpanel JavaScript browser client library",
5
5
  "main": "dist/mixpanel.cjs.js",
6
6
  "directories": {
@@ -54,7 +54,6 @@
54
54
  "request": "2.88.0",
55
55
  "rollup": "2.79.1",
56
56
  "rollup-plugin-esbuild": "4.10.3",
57
- "rollup-plugin-npm": "1.4.0",
58
57
  "sinon": "8.1.1",
59
58
  "sinon-chai": "3.5.0",
60
59
  "webpack": "1.12.2"
package/rollup.config.js CHANGED
@@ -1,11 +1,11 @@
1
- import npm from 'rollup-plugin-npm';
1
+ import nodeResolve from '@rollup/plugin-node-resolve';
2
2
 
3
3
  export default {
4
4
  plugins: [
5
- npm({
5
+ nodeResolve({
6
6
  browser: true,
7
7
  main: true,
8
8
  jsnext: true,
9
9
  })
10
10
  ]
11
- }
11
+ };
package/src/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  var Config = {
2
2
  DEBUG: false,
3
- LIB_VERSION: '2.53.0'
3
+ LIB_VERSION: '2.54.0'
4
4
  };
5
5
 
6
6
  export default Config;
@@ -0,0 +1,22 @@
1
+ // For loading separate bundles asynchronously via script tag
2
+ // so that we don't load them until they are needed at runtime.
3
+ export function loadAsync (src, onload) {
4
+ var scriptEl = document.createElement('script');
5
+ scriptEl.type = 'text/javascript';
6
+ scriptEl.async = true;
7
+ scriptEl.onload = onload;
8
+ scriptEl.src = src;
9
+ document.head.appendChild(scriptEl);
10
+ }
11
+
12
+ // For builds that have everything in one bundle, no extra work.
13
+ export function loadNoop (_src, onload) {
14
+ onload();
15
+ }
16
+
17
+ // For builds that do NOT want any extra bundles (e.g. session recorder)
18
+ // and just the main SDK, throw an error when trying to load a separate bundle.
19
+ // eslint-disable-next-line no-unused-vars
20
+ export function loadThrowError (src, _onload) {
21
+ throw new Error('This build of Mixpanel only includes core SDK functionality, could not load ' + src);
22
+ }
@@ -1,4 +1,5 @@
1
1
  /* eslint camelcase: "off" */
2
2
  import { init_from_snippet } from '../mixpanel-core';
3
+ import {loadAsync} from './bundle-loaders';
3
4
 
4
- init_from_snippet();
5
+ init_from_snippet(loadAsync);
@@ -0,0 +1,7 @@
1
+ /* eslint camelcase: "off" */
2
+ import {init_as_module} from '../mixpanel-core.js';
3
+ import {loadThrowError} from './bundle-loaders.js';
4
+
5
+ var mixpanel = init_as_module(loadThrowError);
6
+
7
+ export default mixpanel;
@@ -0,0 +1,7 @@
1
+ /* eslint camelcase: "off" */
2
+ import {init_as_module} from '../mixpanel-core.js';
3
+ import {loadAsync} from './bundle-loaders.js';
4
+
5
+ var mixpanel = init_as_module(loadAsync);
6
+
7
+ export default mixpanel;
@@ -1,6 +1,9 @@
1
1
  /* eslint camelcase: "off" */
2
+ import '../recorder';
3
+
2
4
  import { init_as_module } from '../mixpanel-core';
5
+ import { loadNoop } from './bundle-loaders';
3
6
 
4
- var mixpanel = init_as_module();
7
+ var mixpanel = init_as_module(loadNoop);
5
8
 
6
9
  export default mixpanel;
@@ -47,6 +47,12 @@ Globals should be all caps
47
47
  */
48
48
 
49
49
  var init_type; // MODULE or SNIPPET loader
50
+ // allow bundlers to specify how extra code (recorder bundle) should be loaded
51
+ // eslint-disable-next-line no-unused-vars
52
+ var load_extra_bundle = function(src, _onload) {
53
+ throw new Error(src + ' not available in this build.');
54
+ };
55
+
50
56
  var mixpanel_master; // main mixpanel instance / object
51
57
  var INIT_MODULE = 0;
52
58
  var INIT_SNIPPET = 1;
@@ -140,7 +146,9 @@ var DEFAULT_CONFIG = {
140
146
  'hooks': {},
141
147
  'record_block_class': new RegExp('^(mp-block|fs-exclude|amp-block|rr-block|ph-no-capture)$'),
142
148
  'record_block_selector': 'img, video',
149
+ 'record_collect_fonts': false,
143
150
  'record_idle_timeout_ms': 30 * 60 * 1000, // 30 minutes
151
+ 'record_inline_images': false,
144
152
  'record_mask_text_class': new RegExp('^(mp-mask|fs-mask|amp-mask|rr-mask|ph-mask)$'),
145
153
  'record_mask_text_selector': '*',
146
154
  'record_max_ms': MAX_RECORDING_MS,
@@ -376,12 +384,7 @@ MixpanelLib.prototype.start_session_recording = addOptOutCheckMixpanelLib(functi
376
384
  }, this);
377
385
 
378
386
  if (_.isUndefined(window['__mp_recorder'])) {
379
- var scriptEl = document.createElement('script');
380
- scriptEl.type = 'text/javascript';
381
- scriptEl.async = true;
382
- scriptEl.onload = handleLoadedRecorder;
383
- scriptEl.src = this.get_config('recorder_src');
384
- document.head.appendChild(scriptEl);
387
+ load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
385
388
  } else {
386
389
  handleLoadedRecorder();
387
390
  }
@@ -680,7 +683,8 @@ MixpanelLib.prototype._send_request = function(url, data, options, callback) {
680
683
  lib.report_error(error);
681
684
  if (callback) {
682
685
  if (verbose_mode) {
683
- callback({status: 0, error: error, xhr_req: req});
686
+ var response_headers = req['responseHeaders'] || {};
687
+ callback({status: 0, httpStatusCode: req['status'], error: error, retryAfter: response_headers['Retry-After']});
684
688
  } else {
685
689
  callback(0);
686
690
  }
@@ -780,6 +784,7 @@ MixpanelLib.prototype.init_batchers = function() {
780
784
  attrs.queue_key,
781
785
  {
782
786
  libConfig: this['config'],
787
+ errorReporter: this.get_config('error_reporter'),
783
788
  sendRequestFunc: _.bind(function(data, options, cb) {
784
789
  this._send_request(
785
790
  this.get_config('api_host') + attrs.endpoint,
@@ -791,8 +796,8 @@ MixpanelLib.prototype.init_batchers = function() {
791
796
  beforeSendHook: _.bind(function(item) {
792
797
  return this._run_hook('before_send_' + attrs.type, item);
793
798
  }, this),
794
- errorReporter: this.get_config('error_reporter'),
795
- stopAllBatchingFunc: _.bind(this.stop_batch_senders, this)
799
+ stopAllBatchingFunc: _.bind(this.stop_batch_senders, this),
800
+ usePersistence: true
796
801
  }
797
802
  );
798
803
  }, this);
@@ -2241,7 +2246,8 @@ var add_dom_loaded_handler = function() {
2241
2246
  _.register_event(window, 'load', dom_loaded_handler, true);
2242
2247
  };
2243
2248
 
2244
- export function init_from_snippet() {
2249
+ export function init_from_snippet(bundle_loader) {
2250
+ load_extra_bundle = bundle_loader;
2245
2251
  init_type = INIT_SNIPPET;
2246
2252
  mixpanel_master = window[PRIMARY_INSTANCE_NAME];
2247
2253
 
@@ -2281,7 +2287,8 @@ export function init_from_snippet() {
2281
2287
  add_dom_loaded_handler();
2282
2288
  }
2283
2289
 
2284
- export function init_as_module() {
2290
+ export function init_as_module(bundle_loader) {
2291
+ load_extra_bundle = bundle_loader;
2285
2292
  init_type = INIT_MODULE;
2286
2293
  mixpanel_master = new MixpanelLib();
2287
2294
 
@@ -1,11 +1,36 @@
1
- import {default as record} from 'rrweb/es/rrweb/packages/rrweb/src/record/index.js';
1
+ import { record } from 'rrweb';
2
+ import { IncrementalSource, EventType } from '@rrweb/types';
2
3
 
3
- import { MAX_RECORDING_MS, console_with_prefix, _ } from '../utils'; // eslint-disable-line camelcase
4
+ import { MAX_RECORDING_MS, console_with_prefix, _, window} from '../utils'; // eslint-disable-line camelcase
4
5
  import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
6
+ import { RequestBatcher } from '../request-batcher';
5
7
 
6
8
  var logger = console_with_prefix('recorder');
7
9
  var CompressionStream = window['CompressionStream'];
8
10
 
11
+ var RECORDER_BATCHER_LIB_CONFIG = {
12
+ 'batch_size': 1000,
13
+ 'batch_flush_interval_ms': 10 * 1000,
14
+ 'batch_request_timeout_ms': 90 * 1000,
15
+ 'batch_autostart': true
16
+ };
17
+
18
+ var ACTIVE_SOURCES = new Set([
19
+ IncrementalSource.MouseMove,
20
+ IncrementalSource.MouseInteraction,
21
+ IncrementalSource.Scroll,
22
+ IncrementalSource.ViewportResize,
23
+ IncrementalSource.Input,
24
+ IncrementalSource.TouchMove,
25
+ IncrementalSource.MediaInteraction,
26
+ IncrementalSource.Drag,
27
+ IncrementalSource.Selection,
28
+ ]);
29
+
30
+ function isUserEvent(ev) {
31
+ return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.source);
32
+ }
33
+
9
34
  var MixpanelRecorder = function(mixpanelInstance) {
10
35
  this._mixpanel = mixpanelInstance;
11
36
 
@@ -16,14 +41,24 @@ var MixpanelRecorder = function(mixpanelInstance) {
16
41
  this.seqNo = 0;
17
42
  this.replayId = null;
18
43
  this.replayStartTime = null;
19
- this.batchStartTime = null;
20
- this.replayLengthMs = 0;
21
44
  this.sendBatchId = null;
22
45
 
23
46
  this.idleTimeoutId = null;
24
47
  this.maxTimeoutId = null;
25
48
 
26
49
  this.recordMaxMs = MAX_RECORDING_MS;
50
+ this._initBatcher();
51
+ };
52
+
53
+
54
+ MixpanelRecorder.prototype._initBatcher = function () {
55
+ this.batcher = new RequestBatcher('__mprec', {
56
+ libConfig: RECORDER_BATCHER_LIB_CONFIG,
57
+ sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
58
+ errorReporter: _.bind(this.reportError, this),
59
+ flushOnlyOnInterval: true,
60
+ usePersistence: false
61
+ });
27
62
  };
28
63
 
29
64
  // eslint-disable-next-line camelcase
@@ -45,12 +80,11 @@ MixpanelRecorder.prototype.startRecording = function () {
45
80
 
46
81
  this.recEvents = [];
47
82
  this.seqNo = 0;
48
- this.startDate = new Date();
49
- this.replayStartTime = this.startDate.getTime();
50
- this.batchStartTime = this.replayStartTime;
83
+ this.replayStartTime = null;
51
84
 
52
85
  this.replayId = _.UUID();
53
- this.replayLengthMs = 0;
86
+
87
+ this.batcher.start();
54
88
 
55
89
  var resetIdleTimeout = _.bind(function () {
56
90
  clearTimeout(this.idleTimeoutId);
@@ -62,20 +96,22 @@ MixpanelRecorder.prototype.startRecording = function () {
62
96
 
63
97
  this._stopRecording = record({
64
98
  'emit': _.bind(function (ev) {
65
- this.recEvents.push(ev);
66
- this.replayLengthMs = new Date().getTime() - this.replayStartTime;
67
- resetIdleTimeout();
99
+ this.batcher.enqueue(ev);
100
+ if (isUserEvent(ev)) {
101
+ resetIdleTimeout();
102
+ }
68
103
  }, this),
69
- 'maskAllInputs': true,
70
- 'maskTextSelector': this.get_config('record_mask_text_selector'),
104
+ 'blockClass': this.get_config('record_block_class'),
71
105
  'blockSelector': this.get_config('record_block_selector'),
106
+ 'collectFonts': this.get_config('record_collect_fonts'),
107
+ 'inlineImages': this.get_config('record_inline_images'),
108
+ 'maskAllInputs': true,
72
109
  'maskTextClass': this.get_config('record_mask_text_class'),
73
- 'blockClass': this.get_config('record_block_class'),
110
+ 'maskTextSelector': this.get_config('record_mask_text_selector')
74
111
  });
75
112
 
76
113
  resetIdleTimeout();
77
114
 
78
- this.sendBatchId = setInterval(_.bind(this.flushEventsWithOptOut, this), 10000);
79
115
  this.maxTimeoutId = setTimeout(_.bind(this.resetRecording, this), this.recordMaxMs);
80
116
  };
81
117
 
@@ -90,10 +126,9 @@ MixpanelRecorder.prototype.stopRecording = function () {
90
126
  this._stopRecording = null;
91
127
  }
92
128
 
93
- this._flushEvents(); // flush any remaining events
129
+ this.batcher.flush(); // flush any remaining events
94
130
  this.replayId = null;
95
131
 
96
- clearInterval(this.sendBatchId);
97
132
  clearTimeout(this.idleTimeoutId);
98
133
  clearTimeout(this.maxTimeoutId);
99
134
  };
@@ -102,8 +137,8 @@ MixpanelRecorder.prototype.stopRecording = function () {
102
137
  * Flushes the current batch of events to the server, but passes an opt-out callback to make sure
103
138
  * we stop recording and dump any queued events if the user has opted out.
104
139
  */
105
- MixpanelRecorder.prototype.flushEventsWithOptOut = function () {
106
- this._flushEvents(_.bind(this._onOptOut, this));
140
+ MixpanelRecorder.prototype.flushEventsWithOptOut = function (data, options, cb) {
141
+ this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
107
142
  };
108
143
 
109
144
  MixpanelRecorder.prototype._onOptOut = function (code) {
@@ -114,33 +149,60 @@ MixpanelRecorder.prototype._onOptOut = function (code) {
114
149
  }
115
150
  };
116
151
 
117
- MixpanelRecorder.prototype._sendRequest = function(reqParams, reqBody) {
152
+ MixpanelRecorder.prototype._sendRequest = function(reqParams, reqBody, callback) {
153
+ var onSuccess = _.bind(function (response, responseBody) {
154
+ // Increment sequence counter only if the request was successful to guarantee ordering.
155
+ // RequestBatcher will always flush the next batch after the previous one succeeds.
156
+ if (response.status === 200) {
157
+ this.seqNo++;
158
+ }
159
+
160
+ callback({
161
+ status: 0,
162
+ httpStatusCode: response.status,
163
+ responseBody: responseBody,
164
+ retryAfter: response.headers.get('Retry-After')
165
+ });
166
+ }, this);
167
+
118
168
  window['fetch'](this.get_config('api_host') + '/' + this.get_config('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
119
169
  'method': 'POST',
120
170
  'headers': {
121
171
  'Authorization': 'Basic ' + btoa(this.get_config('token') + ':'),
122
172
  'Content-Type': 'application/octet-stream'
123
173
  },
124
- 'body': reqBody
174
+ 'body': reqBody,
175
+ }).then(function (response) {
176
+ response.json().then(function (responseBody) {
177
+ onSuccess(response, responseBody);
178
+ }).catch(function (error) {
179
+ callback({error: error});
180
+ });
181
+ }).catch(function (error) {
182
+ callback({error: error});
125
183
  });
126
184
  };
127
185
 
128
- /**
129
- * @api private
130
- * Private method, flushes the current batch of events to the server.
131
- */
132
- MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function() {
133
- var numEvents = this.recEvents.length;
186
+ MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
187
+ const numEvents = data.length;
188
+
134
189
  if (numEvents > 0) {
190
+ // each rrweb event has a timestamp - leverage those to get time properties
191
+ var batchStartTime = data[0].timestamp;
192
+ if (this.seqNo === 0) {
193
+ this.replayStartTime = batchStartTime;
194
+ }
195
+ var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
196
+
135
197
  var reqParams = {
136
198
  'distinct_id': String(this._mixpanel.get_distinct_id()),
137
- 'seq': this.seqNo++,
138
- 'batch_start_time': this.batchStartTime / 1000,
199
+ 'seq': this.seqNo,
200
+ 'batch_start_time': batchStartTime / 1000,
139
201
  'replay_id': this.replayId,
140
- 'replay_length_ms': this.replayLengthMs,
202
+ 'replay_length_ms': replayLengthMs,
141
203
  'replay_start_time': this.replayStartTime / 1000
142
204
  };
143
- var eventsJson = _.JSONEncode(this.recEvents);
205
+ var eventsJson = _.JSONEncode(data);
144
206
 
145
207
  // send ID management props if they exist
146
208
  var deviceId = this._mixpanel.get_property('$device_id');
@@ -152,8 +214,6 @@ MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function() {
152
214
  reqParams['$user_id'] = userId;
153
215
  }
154
216
 
155
- this.recEvents = this.recEvents.slice(numEvents);
156
- this.batchStartTime = new Date().getTime();
157
217
  if (CompressionStream) {
158
218
  var jsonStream = new Blob([eventsJson], {type: 'application/json'}).stream();
159
219
  var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
@@ -161,13 +221,27 @@ MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function() {
161
221
  .blob()
162
222
  .then(_.bind(function(compressedBlob) {
163
223
  reqParams['format'] = 'gzip';
164
- this._sendRequest(reqParams, compressedBlob);
224
+ this._sendRequest(reqParams, compressedBlob, callback);
165
225
  }, this));
166
226
  } else {
167
227
  reqParams['format'] = 'body';
168
- this._sendRequest(reqParams, eventsJson);
228
+ this._sendRequest(reqParams, eventsJson, callback);
169
229
  }
170
230
  }
171
231
  });
172
232
 
233
+
234
+ MixpanelRecorder.prototype.reportError = function(msg, err) {
235
+ logger.error.apply(logger.error, arguments);
236
+ try {
237
+ if (!err && !(msg instanceof Error)) {
238
+ msg = new Error(msg);
239
+ }
240
+ this.get_config('error_reporter')(msg, err);
241
+ } catch(err) {
242
+ logger.error(err);
243
+ }
244
+ };
245
+
246
+
173
247
  window['__mp_recorder'] = MixpanelRecorder;
@@ -17,7 +17,8 @@ var RequestBatcher = function(storageKey, options) {
17
17
  this.errorReporter = options.errorReporter;
18
18
  this.queue = new RequestQueue(storageKey, {
19
19
  errorReporter: _.bind(this.reportError, this),
20
- storage: options.storage
20
+ storage: options.storage,
21
+ usePersistence: options.usePersistence
21
22
  });
22
23
 
23
24
  this.libConfig = options.libConfig;
@@ -34,6 +35,11 @@ var RequestBatcher = function(storageKey, options) {
34
35
 
35
36
  // extra client-side dedupe
36
37
  this.itemIdsSentSuccessfully = {};
38
+
39
+ // Make the flush occur at the interval specified by flushIntervalMs, default behavior will attempt consecutive flushes
40
+ // as long as the queue is not empty. This is useful for high-frequency events like Session Replay where we might end up
41
+ // in a request loop and get ratelimited by the server.
42
+ this.flushOnlyOnInterval = options.flushOnlyOnInterval || false;
37
43
  };
38
44
 
39
45
  /**
@@ -118,6 +124,9 @@ RequestBatcher.prototype.flush = function(options) {
118
124
  var startTime = new Date().getTime();
119
125
  var currentBatchSize = this.batchSize;
120
126
  var batch = this.queue.fillBatch(currentBatchSize);
127
+ // if there's more items in the queue than the batch size, attempt
128
+ // to flush again after the current batch is done.
129
+ var attemptSecondaryFlush = batch.length === currentBatchSize;
121
130
  var dataForRequest = [];
122
131
  var transformedItems = {};
123
132
  _.each(batch, function(item) {
@@ -185,22 +194,17 @@ RequestBatcher.prototype.flush = function(options) {
185
194
  this.flush();
186
195
  } else if (
187
196
  _.isObject(res) &&
188
- res.xhr_req &&
189
- (res.xhr_req['status'] >= 500 || res.xhr_req['status'] === 429 || res.error === 'timeout')
197
+ (res.httpStatusCode >= 500 || res.httpStatusCode === 429 || res.error === 'timeout')
190
198
  ) {
191
199
  // network or API error, or 429 Too Many Requests, retry
192
200
  var retryMS = this.flushInterval * 2;
193
- var headers = res.xhr_req['responseHeaders'];
194
- if (headers) {
195
- var retryAfter = headers['Retry-After'];
196
- if (retryAfter) {
197
- retryMS = (parseInt(retryAfter, 10) * 1000) || retryMS;
198
- }
201
+ if (res.retryAfter) {
202
+ retryMS = (parseInt(res.retryAfter, 10) * 1000) || retryMS;
199
203
  }
200
204
  retryMS = Math.min(MAX_RETRY_INTERVAL_MS, retryMS);
201
205
  this.reportError('Error; retry in ' + retryMS + ' ms');
202
206
  this.scheduleFlush(retryMS);
203
- } else if (_.isObject(res) && res.xhr_req && res.xhr_req['status'] === 413) {
207
+ } else if (_.isObject(res) && res.httpStatusCode === 413) {
204
208
  // 413 Payload Too Large
205
209
  if (batch.length > 1) {
206
210
  var halvedBatchSize = Math.max(1, Math.floor(currentBatchSize / 2));
@@ -224,7 +228,11 @@ RequestBatcher.prototype.flush = function(options) {
224
228
  _.bind(function(succeeded) {
225
229
  if (succeeded) {
226
230
  this.consecutiveRemovalFailures = 0;
227
- this.flush(); // handle next batch if the queue isn't empty
231
+ if (this.flushOnlyOnInterval && !attemptSecondaryFlush) {
232
+ this.resetFlush(); // schedule next batch with a delay
233
+ } else {
234
+ this.flush(); // handle next batch if the queue isn't empty
235
+ }
228
236
  } else {
229
237
  this.reportError('Failed to remove items from queue');
230
238
  if (++this.consecutiveRemovalFailures > 5) {
@@ -272,7 +280,6 @@ RequestBatcher.prototype.flush = function(options) {
272
280
  }
273
281
  logger.log('MIXPANEL REQUEST:', dataForRequest);
274
282
  this.sendRequest(dataForRequest, requestOptions, batchSendCallback);
275
-
276
283
  } catch(err) {
277
284
  this.reportError('Error flushing request queue', err);
278
285
  this.resetFlush();