diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6151437b..84ec86bc 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -16,9 +16,28 @@ jobs:
steps:
- uses: actions/checkout@v4
+
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
+
+ - name: Cache node modules
+ uses: actions/cache@v3
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-node-${{ matrix.node-version }}-
+ ${{ runner.os }}-node-
+
+ # Configure npm for better reliability
+ - name: Configure npm
+ run: |
+ npm config set fetch-retries 5
+ npm config set fetch-retry-mintimeout 20000
+ npm config set fetch-retry-maxtimeout 120000
+ npm config set maxsockets 1
+
- run: npm ci
- - run: npm test
+ - run: npm test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 2375ef02..87877c49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ tunnel.log
*.ps1
*.bundle.js
.DS_Store
+examples/heartbeat-demo
\ No newline at end of file
diff --git a/.mocharc.json b/.mocharc.json
new file mode 100644
index 00000000..790560b3
--- /dev/null
+++ b/.mocharc.json
@@ -0,0 +1,8 @@
+{
+ "require": ["babel-core/register"],
+ "spec": ["tests/unit/*.js"],
+ "ignore": ["tests/unit/test-utils/**/*.js"],
+ "timeout": 5000,
+ "ui": "bdd",
+ "recursive": false
+}
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000..96332c7d
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "hbenl.vscode-mocha-test-adapter"
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..e939c3a5
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,35 @@
+{
+ "mochaExplorer.files": "tests/unit/*.js",
+ "mochaExplorer.require": ["babel-core/register"],
+ "mochaExplorer.env": {
+ "BABEL_ENV": "test"
+ },
+ "mochaExplorer.timeout": 5000,
+ "mochaExplorer.ui": "bdd",
+ "mochaExplorer.mochaPath": "./node_modules/mocha",
+ "mochaExplorer.logpanel": true,
+ "mochaExplorer.autoload": true,
+ "testExplorer.codeLens": true,
+ "testExplorer.gutterDecoration": true,
+ "testExplorer.onStart": "retire",
+"testExplorer.onReload": "retire",
+"cSpell.words": [
+ "Ashkenas",
+ "avocat",
+ "copypasta",
+ "esque",
+ "FLUSHON",
+ "mainsite",
+ "mixpanelinit",
+ "mpap",
+ "mphb",
+ "mphbf",
+ "mplib",
+ "mpso",
+ "mpus",
+ "optout",
+ "sendbeacon",
+ "superproperties",
+ "superprops"
+]
+}
\ No newline at end of file
diff --git a/doc/readme.io/javascript-full-api-reference.md b/doc/readme.io/javascript-full-api-reference.md
index d8a8b8bb..f6eaeae3 100644
--- a/doc/readme.io/javascript-full-api-reference.md
+++ b/doc/readme.io/javascript-full-api-reference.md
@@ -248,6 +248,173 @@ var has_opted_out = mixpanel.has_opted_out_tracking();
| boolean | current opt-out status |
+___
+## mixpanel.heartbeat
+Client-side aggregation for streaming analytics events like video watch time, podcast listen time, or other continuous interactions. `mixpanel.heartbeat()` is safe to be called in a loop without exploding your event counts.
+
+Heartbeat produces a single event which represents many heartbeats; the event which summarizes all the heartbeats is sent when the user stops sending heartbeats for a configurable timeout period (default 30 seconds) or when the page unloads.
+
+**Note**: Heartbeat data is session-scoped and does not persist across page refreshes. All pending heartbeat events are automatically flushed when the page unloads.
+
+Each summary event automatically tracks:
+- `$duration`: Seconds from first to last heartbeat call
+- `$heartbeats`: Number of heartbeat calls made
+- `$contentId`: The contentId parameter
+
+### Basic Usage:
+```javascript
+mixpanel.heartbeat('video_watch', 'video_123');
+mixpanel.heartbeat('video_watch', 'video_123'); // 10 seconds later
+mixpanel.heartbeat('video_watch', 'video_123'); // 30 seconds later
+// After 30 seconds of inactivity, the event is flushed:
+// {event: 'video_watch', properties: {$contentId: 'video_123', $duration: 40, $heartbeats: 3}}
+```
+
+You can also pass additional properties, and options to be aggregated with each heartbeat call. Properties are merged intelligently by type:
+- Numbers take the latest value
+- Strings take the latest value
+- Objects are merged (latest overwrites)
+- Arrays have elements appended
+
+### Examples:
+
+```javascript
+// Force immediate flush
+mixpanel.heartbeat('podcast_listen', 'episode_123', { platform: 'mobile' }, { forceFlush: true });
+
+// Custom timeout (60 seconds)
+mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' }, { timeout: 60000 });
+
+// Property aggregation
+mixpanel.heartbeat('video_watch', 'video_123', {
+ currentTime: 30,
+ interactions: ['play'],
+ language: 'en'
+});
+
+mixpanel.heartbeat('video_watch', 'video_123', {
+ currentTime: 45, // latest value: {currentTime: 45}
+ interactions: ['pause'], // appended: ['play', 'pause']
+ language: 'fr' // replaced: {language: 'fr'}
+});
+```
+
+### Auto-Flush Behavior:
+Events are automatically flushed when:
+- **Time limit reached**: No activity for 30 seconds (or custom timeout)
+- **Page unload**: Browser navigation or tab close (uses sendBeacon for reliability)
+
+**Session Scope**: All heartbeat data is stored in memory only and is lost when the page refreshes or navigates away. This design ensures reliable data transmission without cross-page persistence complexity.
+
+
+| Argument | Type | Description |
+| ------------- | ------------- | ----- |
+| **event_name** | Stringrequired | The name of the event to track |
+| **content_id** | Stringrequired | Unique identifier for the content being tracked |
+| **properties** | Objectoptional | Properties to aggregate with existing data |
+| **options** | Objectoptional | Configuration options |
+| **options.timeout** | Numberoptional | Timeout in milliseconds (default 30000) |
+| **options.forceFlush** | Booleanoptional | Force immediate flush after aggregation |
+
+
+
+___
+## mixpanel.heartbeat.start
+Start a managed heartbeat that automatically sends heartbeat calls at regular intervals. This is ideal for tracking continuous activities like video watching or audio playback where you want automated tracking without manual heartbeat() calls.
+
+**Important**: You cannot mix `mixpanel.heartbeat()` calls with `mixpanel.heartbeat.start()` for the same event and content ID. Use one approach or the other.
+
+### Basic Usage:
+```javascript
+// Start managed heartbeat with default 5-second interval
+mixpanel.heartbeat.start('video_watch', 'video_123');
+
+// Stop the managed heartbeat when user stops watching
+mixpanel.heartbeat.stop('video_watch', 'video_123');
+```
+
+### Custom Interval:
+```javascript
+// Start with custom 10-second interval
+mixpanel.heartbeat.start('podcast_listen', 'episode_456',
+ { platform: 'mobile' },
+ { interval: 10000 }
+);
+```
+
+### Property Aggregation:
+Properties passed to `heartbeat.start()` are sent with each interval heartbeat and aggregated the same way as manual heartbeat calls:
+
+```javascript
+mixpanel.heartbeat.start('game_session', 'level_1', {
+ score: 100, // Numbers use latest value each interval
+ level: 'easy', // Strings use latest value
+ powerups: ['speed'] // Arrays have elements appended
+});
+
+// After multiple intervals, properties are aggregated:
+// {score: 100, level: 'easy', powerups: ['speed', 'speed', 'speed']}
+```
+
+### Auto-Management:
+- Automatically calls internal heartbeat at specified intervals (default 5 seconds)
+- Each interval call aggregates the provided properties
+- Includes standard automatic properties: `$duration`, `$heartbeats`, `$contentId`
+- Must be stopped with `mixpanel.heartbeat.stop()` to flush final event
+
+**Session Scope**: Like manual heartbeat calls, managed heartbeats are session-scoped and do not persist across page refreshes.
+
+
+| Argument | Type | Description |
+| ------------- | ------------- | ----- |
+| **event_name** | Stringrequired | The name of the event to track |
+| **content_id** | Stringrequired | Unique identifier for the content being tracked |
+| **properties** | Objectoptional | Properties to include with each heartbeat interval |
+| **options** | Objectoptional | Configuration options |
+| **options.interval** | Numberoptional | Interval in milliseconds between heartbeats (default 5000) |
+
+
+___
+## mixpanel.heartbeat.stop
+Stop a managed heartbeat started with `mixpanel.heartbeat.start()` and immediately flush the aggregated event data.
+
+### Basic Usage:
+```javascript
+// Start managed tracking
+mixpanel.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+
+// Stop and flush when user stops watching (e.g., pause, close)
+mixpanel.heartbeat.stop('video_watch', 'video_123');
+```
+
+### Immediate Flush:
+When `heartbeat.stop()` is called:
+1. The interval timer is immediately cleared (no more automatic heartbeats)
+2. Any accumulated heartbeat data is immediately flushed as a track event
+3. The event includes all aggregated properties and automatic properties
+
+### Multiple Concurrent Heartbeats:
+You can run multiple managed heartbeats simultaneously:
+
+```javascript
+// Start multiple different content tracking
+mixpanel.heartbeat.start('video_watch', 'video_123');
+mixpanel.heartbeat.start('podcast_listen', 'episode_456');
+
+// Stop them independently
+mixpanel.heartbeat.stop('video_watch', 'video_123'); // Flushes video data
+mixpanel.heartbeat.stop('podcast_listen', 'episode_456'); // Flushes podcast data
+```
+
+**Note**: Calling `stop()` on a non-existent heartbeat is safe and will not produce errors.
+
+
+| Argument | Type | Description |
+| ------------- | ------------- | ----- |
+| **event_name** | Stringrequired | The name of the event to stop tracking |
+| **content_id** | Stringrequired | Unique identifier for the content to stop tracking |
+
+
___
## mixpanel.identify
Identify a user with a unique ID to track user activity across devices, tie a user to their events, and create a user profile. If you never call this method, unique visitors are tracked using a UUID generated the first time they visit the site.
diff --git a/examples/commonjs-browserify/bundle.js b/examples/commonjs-browserify/bundle.js
index 5bf4ded9..381fd663 100644
--- a/examples/commonjs-browserify/bundle.js
+++ b/examples/commonjs-browserify/bundle.js
@@ -20700,7 +20700,7 @@ MixpanelLib.prototype._init = function(token, config, name) {
if (!this.get_distinct_id()) {
// There is no need to set the distinct id
// or the device id if something was already stored
- // in the persitence
+ // in the persistence
this.register_once({
'distinct_id': DEVICE_ID_PREFIX + uuid,
'$device_id': uuid
@@ -20724,6 +20724,7 @@ MixpanelLib.prototype._init = function(token, config, name) {
this._init_tab_id();
this._check_and_start_session_recording();
+ this._init_heartbeat();
};
/**
@@ -21460,6 +21461,505 @@ MixpanelLib.prototype.track = addOptOutCheckMixpanelLib(function(event_name, pro
return ret;
});
+/**
+ * Initializes the heartbeat tracking system for the instance
+ * @private
+ */
+MixpanelLib.prototype._init_heartbeat = function() {
+ var self = this;
+
+ // Internal heartbeat state storage
+ this._heartbeat_timers = new Map();
+ this._heartbeat_storage = {}; // In-memory storage for heartbeat events
+ this._heartbeat_unload_setup = false;
+ // State tracking for start/stop vs manual heartbeat APIs
+ this._heartbeat_intervals = new Map(); // Track active start/stop intervals
+ this._heartbeat_manual_events = new Set(); // Track events managed by manual heartbeat() calls
+ this._heartbeat_managed_events = new Set(); // Track events managed by start/stop
+
+ // Setup page unload handlers once
+ this._setup_heartbeat_unload_handlers();
+
+ /**
+ * Client-side aggregation for streaming analytics events like video watch time,
+ * podcast listen time, or other continuous interactions. Designed to be called
+ * in loops without exploding row counts.
+ *
+ * Heartbeat works by aggregating properties client-side until the event is flushed.
+ * Properties are merged intelligently:
+ * - Numbers are added together
+ * - Strings take the latest value
+ * - Objects are merged (latest overwrites)
+ * - Arrays have elements appended
+ *
+ * Events auto-flush after 30 seconds (configurable) or on page unload.
+ *
+ * Each event automatically tracks:
+ * - $duration: Seconds from first to last heartbeat call
+ * - $heartbeats: Number of heartbeat calls made
+ * - $contentId: The contentId parameter
+ *
+ * @function heartbeat
+ * @memberof mixpanel
+ * @param {String} eventName The name of the event to track
+ * @param {String} contentId Unique identifier for the content being tracked
+ * @param {Object} [props] Properties to aggregate with existing data
+ * @param {Object} [options] Configuration options
+ * @param {Number} [options.timeout] Timeout in milliseconds (default 30000)
+ * @param {Boolean} [options.forceFlush] Force immediate flush after aggregation
+ * @returns {Void}
+ *
+ * @example
+ * // Basic video tracking
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' });
+ * mixpanel.heartbeat('video_watch', 'video_123', { position: 30 });
+ * // After 30 seconds: { quality: 'HD', position: 30, $duration: 30, $heartbeats: 2 }
+ *
+ * @example
+ * // Force immediate flush
+ * mixpanel.heartbeat('podcast_listen', 'episode_123', { platform: 'mobile' }, { forceFlush: true });
+ *
+ * @example
+ * // Custom timeout (60 seconds)
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' }, { timeout: 60000 });
+ */
+ this.heartbeat = function(eventName, contentId, props, options) {
+ return self._heartbeat_impl(eventName, contentId, props, options);
+ };
+
+ // Add start/stop methods to the heartbeat function
+ this.heartbeat.start = function(eventName, contentId, props, options) {
+ return self._heartbeat_start_impl(eventName, contentId, props, options);
+ };
+
+ this.heartbeat.stop = function(eventName, contentId) {
+ return self._heartbeat_stop_impl(eventName, contentId);
+ };
+
+};
+
+/**
+ * Sets up page unload handlers for heartbeat auto-flush
+ * @private
+ */
+MixpanelLib.prototype._setup_heartbeat_unload_handlers = function() {
+ if (this._heartbeat_unload_setup) {
+ return;
+ }
+ this._heartbeat_unload_setup = true;
+
+ var self = this;
+ var hasUnloaded = false;
+ var handleUnload = function() {
+ if (hasUnloaded) return;
+ hasUnloaded = true;
+ self._heartbeat_log('Page unload detected, flushing all heartbeat events');
+ self._heartbeat_flush_all('pageUnload', true);
+ };
+
+ // Multiple event handlers for cross-browser compatibility
+ if (win.addEventListener) {
+ win.addEventListener('beforeunload', handleUnload);
+ win.addEventListener('pagehide', handleUnload);
+ win.addEventListener('visibilitychange', function() {
+ if (document$1.visibilityState === 'hidden') {
+ handleUnload();
+ }
+ });
+ }
+};
+
+/**
+ * Gets heartbeat event storage from memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_get_storage = function() {
+ return this._heartbeat_storage || {};
+};
+
+/**
+ * Saves heartbeat events to memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_save_storage = function(data) {
+ this._heartbeat_storage = data;
+};
+
+
+/**
+ * Logs heartbeat debug messages if logging is enabled
+ * Logs when either global debug is true
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_log = function() {
+ var globalDebugEnabled = this.get_config('debug');
+ if (globalDebugEnabled) {
+ var args = Array.prototype.slice.call(arguments);
+ args[0] = '[Mixpanel Heartbeat] ' + args[0];
+ try {
+ if (typeof win !== 'undefined' && win.console && win.console.log) {
+ win.console.log.apply(win.console, args);
+ }
+ } catch (err) {
+ _.each(args, function(arg) {
+ if (typeof win !== 'undefined' && win.console && win.console.log) {
+ win.console.log(arg);
+ }
+ });
+ }
+ }
+};
+
+/**
+ * Aggregates properties according to heartbeat rules
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_aggregate_props = function(existingProps, newProps) {
+ var result = _.extend({}, existingProps);
+ // Remove legacy contentId property in favor of $contentId
+ delete result.contentId;
+
+ _.each(newProps, function(newValue, key) {
+ if (!(key in result)) {
+ result[key] = newValue;
+ } else {
+ var existingValue = result[key];
+ var newType = typeof newValue;
+ var existingType = typeof existingValue;
+
+ if (newType === 'number' && existingType === 'number') {
+ // Add numbers together
+ result[key] = existingValue + newValue;
+ } else if (newType === 'string') {
+ // Replace with new string
+ result[key] = newValue;
+ } else if (newType === 'object' && existingType === 'object') {
+ if (_.isArray(newValue) && _.isArray(existingValue)) {
+ // Concatenate arrays
+ result[key] = existingValue.concat(newValue);
+ } else if (!_.isArray(newValue) && !_.isArray(existingValue)) {
+ // Merge objects (shallow merge with overwrites)
+ result[key] = _.extend({}, existingValue, newValue);
+ } else {
+ // Type mismatch, replace
+ result[key] = newValue;
+ }
+ } else {
+ // For all other cases, replace
+ result[key] = newValue;
+ }
+ }
+ });
+
+ return result;
+};
+
+
+/**
+ * Clears the auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_clear_timer = function(eventKey) {
+ if (this._heartbeat_timers.has(eventKey)) {
+ clearTimeout(this._heartbeat_timers.get(eventKey));
+ this._heartbeat_timers.delete(eventKey);
+ this._heartbeat_log('Cleared flush timer for', eventKey);
+ }
+};
+
+/**
+ * Sets up auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_setup_timer = function(eventKey, timeout) {
+ var self = this;
+ try {
+ self._heartbeat_clear_timer(eventKey);
+
+ var timerId = setTimeout(function() {
+ try {
+ self._heartbeat_log('Auto-flushing due to timeout for', eventKey);
+ self._heartbeat_flush_event(eventKey, 'timeout', false);
+ } catch (e) {
+ self.report_error('Error in heartbeat timeout handler: ' + e.message);
+ }
+ }, timeout || 30000);
+
+ this._heartbeat_timers.set(eventKey, timerId);
+ } catch (e) {
+ self.report_error('Error setting up heartbeat timer: ' + e.message);
+ }
+};
+
+/**
+ * Flushes a single heartbeat event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_event = function(eventKey, reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var eventData = storage[eventKey];
+
+ if (!eventData) {
+ return;
+ }
+
+ var eventName = eventData.eventName;
+ var props = eventData.props;
+
+ // Clear any pending timers
+ this._heartbeat_clear_timer(eventKey);
+
+ // Prepare tracking properties (exclude old contentId property)
+ var trackingProps = _.extend({}, props);
+ delete trackingProps.contentId;
+
+ // Prepare transport options
+ var transportOptions = useSendBeacon ? { transport: 'sendBeacon' } : {};
+
+ try {
+ this.track(eventName, trackingProps, transportOptions);
+ this._heartbeat_log('Flushed event', eventKey, 'reason:', reason, 'props:', trackingProps);
+ } catch (error) {
+ this.report_error('Error flushing heartbeat event: ' + error.message);
+ }
+
+ // Remove from storage after flushing
+ delete storage[eventKey];
+ this._heartbeat_save_storage(storage);
+
+ // Clean up event tracking state
+ this._heartbeat_manual_events.delete(eventKey);
+ this._heartbeat_managed_events.delete(eventKey);
+
+};
+
+/**
+ * Flushes all heartbeat events
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_all = function(reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var keys = Object.keys(storage);
+
+ this._heartbeat_log('Flushing all heartbeat events, count:', keys.length, 'reason:', reason);
+
+ for (var i = 0; i < keys.length; i++) {
+ this._heartbeat_flush_event(keys[i], reason, useSendBeacon);
+ }
+};
+
+/**
+ * Internal heartbeat logic (used by both manual and managed APIs)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_internal = function(eventName, contentId, props, options) {
+ var eventKey = eventName + '|' + contentId;
+ this._heartbeat_log('Heartbeat called for', eventKey, 'props:', props);
+
+ // Get current storage
+ var storage = this._heartbeat_get_storage();
+
+ // Check storage size limit (hardcoded to 500)
+ var storageKeys = Object.keys(storage);
+ if (storageKeys.length >= 500 && !(eventKey in storage)) {
+ this.report_error('heartbeat: Maximum storage size reached, flushing oldest event');
+ // Flush the first (oldest) event to make room
+ var oldestKey = storageKeys[0];
+ this._heartbeat_flush_event(oldestKey, 'maxStorageSize', false);
+ storage = this._heartbeat_get_storage(); // Refresh storage after flush
+ }
+
+ var currentTime = new Date().getTime();
+
+ // Get or create event data
+ if (storage[eventKey]) {
+ // Aggregate with existing data
+ var existingData = storage[eventKey];
+ var aggregatedProps = this._heartbeat_aggregate_props(existingData.props, props);
+
+ // Update automatic tracking properties
+ var durationSeconds = Math.round((currentTime - existingData.firstCall) / 1000);
+ aggregatedProps['$duration'] = durationSeconds;
+ aggregatedProps['$heartbeats'] = (existingData.hitCount || 1) + 1;
+ aggregatedProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: aggregatedProps,
+ lastUpdate: currentTime,
+ firstCall: existingData.firstCall,
+ hitCount: (existingData.hitCount || 1) + 1
+ };
+
+ this._heartbeat_log('Aggregated props for', eventKey, 'new props:', aggregatedProps);
+ } else {
+ // Create new entry
+ var newProps = _.extend({}, props);
+ newProps['$duration'] = 0;
+ newProps['$heartbeats'] = 1;
+ newProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: newProps,
+ lastUpdate: currentTime,
+ firstCall: currentTime,
+ hitCount: 1
+ };
+
+ this._heartbeat_log('Created new heartbeat entry for', eventKey);
+ }
+
+ // Save to persistence
+ this._heartbeat_save_storage(storage);
+
+ // Handle force flush or set up timer (skip timer setup for managed intervals)
+ if (options.forceFlush) {
+ this._heartbeat_log('Force flushing requested');
+ this._heartbeat_flush_event(eventKey, 'forceFlush', false);
+ } else if (!options._managed) {
+ // Set up or reset the auto-flush timer with custom timeout (only for manual heartbeats)
+ var timeout = options.timeout || 30000; // Default 30 seconds
+ this._heartbeat_setup_timer(eventKey, timeout);
+ }
+
+ return;
+};
+
+/**
+ * Main heartbeat implementation (public API)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent manual heartbeat() calls on start/stop managed events
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat: Cannot call heartbeat() on an event managed by heartbeat.start(). Use heartbeat.stop() first.');
+ return;
+ }
+
+ // Track this as a manual heartbeat event
+ this._heartbeat_manual_events.add(eventKey);
+
+ // Call the internal implementation
+ this._heartbeat_internal(eventName, contentId, props, options);
+
+ return;
+});
+
+/**
+ * Start implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_start_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.start: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent start() calls on manual heartbeat events
+ if (this._heartbeat_manual_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Cannot start managed heartbeat on an event already using manual heartbeat() calls. Stop calling heartbeat() first.');
+ return;
+ }
+
+ // Check if already started - warn and restart with new params
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Event already started, restarting with new parameters');
+ this._heartbeat_stop_impl(eventName, contentId);
+ }
+
+ // Track this as a managed heartbeat event
+ this._heartbeat_managed_events.add(eventKey);
+
+ var interval = options.interval || 5000; // Default 5 seconds
+
+ // Validate interval parameter to prevent performance issues
+ if (typeof interval !== 'number' || interval < 100) {
+ this.report_error('heartbeat.start: interval must be a number >= 100ms, using default 5000ms');
+ interval = 5000;
+ }
+ if (interval > 300000) { // 5 minutes max
+ this.report_error('heartbeat.start: interval too large, using maximum 300000ms');
+ interval = 300000;
+ }
+
+ var self = this;
+
+ this._heartbeat_log('Starting managed heartbeat for', eventKey, 'interval:', interval + 'ms');
+
+ // Start the interval
+ var intervalId = setInterval(function() {
+ // Call the internal heartbeat implementation with managed flag to skip timer setup
+ self._heartbeat_internal(eventName, contentId, props, { timeout: 30000, _managed: true });
+ }, interval);
+
+ // Store the interval ID
+ this._heartbeat_intervals.set(eventKey, intervalId);
+
+ return;
+});
+
+/**
+ * Stop implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_stop_impl = addOptOutCheckMixpanelLib(function(eventName, contentId) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.stop: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+
+ var eventKey = eventName + '|' + contentId;
+
+ this._heartbeat_log('Stopping managed heartbeat for', eventKey);
+
+ // Clear the interval if it exists
+ if (this._heartbeat_intervals.has(eventKey)) {
+ clearInterval(this._heartbeat_intervals.get(eventKey));
+ this._heartbeat_intervals.delete(eventKey);
+ }
+
+ // Remove from managed events tracking
+ this._heartbeat_managed_events.delete(eventKey);
+
+ // Force flush the event immediately (as per requirements)
+ this._heartbeat_flush_event(eventKey, 'stop', false);
+
+ return;
+});
+
+
/**
* Register the current user into one/many groups.
*
@@ -22194,6 +22694,22 @@ MixpanelLib.prototype.name_tag = function(name_tag) {
*
* // whether to ignore or respect the web browser's Do Not Track setting
* ignore_dnt: false
+ *
+ * // heartbeat event aggregation settings
+ * // milliseconds to wait before auto-flushing aggregated heartbeat events
+ * heartbeat_max_buffer_time_ms: 30000
+ *
+ * // maximum number of properties per heartbeat event before auto-flush
+ * heartbeat_max_props_count: 1000
+ *
+ * // maximum numeric value for property aggregation before auto-flush
+ * heartbeat_max_aggregated_value: 100000
+ *
+ * // maximum number of events stored in heartbeat queue before auto-flush
+ * heartbeat_max_storage_size: 100
+ *
+ * // enable debug logging for heartbeat events
+ * heartbeat_enable_logging: false
* }
*
*
@@ -22642,7 +23158,7 @@ var override_mp_init_func = function() {
// main mixpanel lib already initialized
instance = instances[PRIMARY_INSTANCE_NAME];
} else if (token) {
- // intialize the main mixpanel lib
+ // initialize the main mixpanel lib
instance = create_mplib(token, config, PRIMARY_INSTANCE_NAME);
instance._loaded();
instances[PRIMARY_INSTANCE_NAME] = instance;
diff --git a/examples/es2015-babelify/bundle.js b/examples/es2015-babelify/bundle.js
index 94806906..dafac02f 100644
--- a/examples/es2015-babelify/bundle.js
+++ b/examples/es2015-babelify/bundle.js
@@ -21368,7 +21368,7 @@ MixpanelLib.prototype._init = function (token, config, name) {
if (!this.get_distinct_id()) {
// There is no need to set the distinct id
// or the device id if something was already stored
- // in the persitence
+ // in the persistence
this.register_once({
'distinct_id': DEVICE_ID_PREFIX + uuid,
'$device_id': uuid
@@ -21392,6 +21392,7 @@ MixpanelLib.prototype._init = function (token, config, name) {
this._init_tab_id();
this._check_and_start_session_recording();
+ this._init_heartbeat();
};
/**
@@ -22109,6 +22110,501 @@ MixpanelLib.prototype.track = (0, _gdprUtils.addOptOutCheckMixpanelLib)(function
return ret;
});
+/**
+ * Initializes the heartbeat tracking system for the instance
+ * @private
+ */
+MixpanelLib.prototype._init_heartbeat = function () {
+ var self = this;
+
+ // Internal heartbeat state storage
+ this._heartbeat_timers = new Map();
+ this._heartbeat_storage = {}; // In-memory storage for heartbeat events
+ this._heartbeat_unload_setup = false;
+ // State tracking for start/stop vs manual heartbeat APIs
+ this._heartbeat_intervals = new Map(); // Track active start/stop intervals
+ this._heartbeat_manual_events = new Set(); // Track events managed by manual heartbeat() calls
+ this._heartbeat_managed_events = new Set(); // Track events managed by start/stop
+
+ // Setup page unload handlers once
+ this._setup_heartbeat_unload_handlers();
+
+ /**
+ * Client-side aggregation for streaming analytics events like video watch time,
+ * podcast listen time, or other continuous interactions. Designed to be called
+ * in loops without exploding row counts.
+ *
+ * Heartbeat works by aggregating properties client-side until the event is flushed.
+ * Properties are merged intelligently:
+ * - Numbers are added together
+ * - Strings take the latest value
+ * - Objects are merged (latest overwrites)
+ * - Arrays have elements appended
+ *
+ * Events auto-flush after 30 seconds (configurable) or on page unload.
+ *
+ * Each event automatically tracks:
+ * - $duration: Seconds from first to last heartbeat call
+ * - $heartbeats: Number of heartbeat calls made
+ * - $contentId: The contentId parameter
+ *
+ * @function heartbeat
+ * @memberof mixpanel
+ * @param {String} eventName The name of the event to track
+ * @param {String} contentId Unique identifier for the content being tracked
+ * @param {Object} [props] Properties to aggregate with existing data
+ * @param {Object} [options] Configuration options
+ * @param {Number} [options.timeout] Timeout in milliseconds (default 30000)
+ * @param {Boolean} [options.forceFlush] Force immediate flush after aggregation
+ * @returns {Void}
+ *
+ * @example
+ * // Basic video tracking
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' });
+ * mixpanel.heartbeat('video_watch', 'video_123', { position: 30 });
+ * // After 30 seconds: { quality: 'HD', position: 30, $duration: 30, $heartbeats: 2 }
+ *
+ * @example
+ * // Force immediate flush
+ * mixpanel.heartbeat('podcast_listen', 'episode_123', { platform: 'mobile' }, { forceFlush: true });
+ *
+ * @example
+ * // Custom timeout (60 seconds)
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' }, { timeout: 60000 });
+ */
+ this.heartbeat = function (eventName, contentId, props, options) {
+ return self._heartbeat_impl(eventName, contentId, props, options);
+ };
+
+ // Add start/stop methods to the heartbeat function
+ this.heartbeat.start = function (eventName, contentId, props, options) {
+ return self._heartbeat_start_impl(eventName, contentId, props, options);
+ };
+
+ this.heartbeat.stop = function (eventName, contentId) {
+ return self._heartbeat_stop_impl(eventName, contentId);
+ };
+};
+
+/**
+ * Sets up page unload handlers for heartbeat auto-flush
+ * @private
+ */
+MixpanelLib.prototype._setup_heartbeat_unload_handlers = function () {
+ if (this._heartbeat_unload_setup) {
+ return;
+ }
+ this._heartbeat_unload_setup = true;
+
+ var self = this;
+ var hasUnloaded = false;
+ var handleUnload = function handleUnload() {
+ if (hasUnloaded) return;
+ hasUnloaded = true;
+ self._heartbeat_log('Page unload detected, flushing all heartbeat events');
+ self._heartbeat_flush_all('pageUnload', true);
+ };
+
+ // Multiple event handlers for cross-browser compatibility
+ if (_window.window.addEventListener) {
+ _window.window.addEventListener('beforeunload', handleUnload);
+ _window.window.addEventListener('pagehide', handleUnload);
+ _window.window.addEventListener('visibilitychange', function () {
+ if (_utils.document.visibilityState === 'hidden') {
+ handleUnload();
+ }
+ });
+ }
+};
+
+/**
+ * Gets heartbeat event storage from memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_get_storage = function () {
+ return this._heartbeat_storage || {};
+};
+
+/**
+ * Saves heartbeat events to memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_save_storage = function (data) {
+ this._heartbeat_storage = data;
+};
+
+/**
+ * Logs heartbeat debug messages if logging is enabled
+ * Logs when either global debug is true
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_log = function () {
+ var globalDebugEnabled = this.get_config('debug');
+ if (globalDebugEnabled) {
+ var args = Array.prototype.slice.call(arguments);
+ args[0] = '[Mixpanel Heartbeat] ' + args[0];
+ try {
+ if (typeof _window.window !== 'undefined' && _window.window.console && _window.window.console.log) {
+ _window.window.console.log.apply(_window.window.console, args);
+ }
+ } catch (err) {
+ _utils._.each(args, function (arg) {
+ if (typeof _window.window !== 'undefined' && _window.window.console && _window.window.console.log) {
+ _window.window.console.log(arg);
+ }
+ });
+ }
+ }
+};
+
+/**
+ * Aggregates properties according to heartbeat rules
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_aggregate_props = function (existingProps, newProps) {
+ var result = _utils._.extend({}, existingProps);
+ // Remove legacy contentId property in favor of $contentId
+ delete result.contentId;
+
+ _utils._.each(newProps, function (newValue, key) {
+ if (!(key in result)) {
+ result[key] = newValue;
+ } else {
+ var existingValue = result[key];
+ var newType = typeof newValue;
+ var existingType = typeof existingValue;
+
+ if (newType === 'number' && existingType === 'number') {
+ // Add numbers together
+ result[key] = existingValue + newValue;
+ } else if (newType === 'string') {
+ // Replace with new string
+ result[key] = newValue;
+ } else if (newType === 'object' && existingType === 'object') {
+ if (_utils._.isArray(newValue) && _utils._.isArray(existingValue)) {
+ // Concatenate arrays
+ result[key] = existingValue.concat(newValue);
+ } else if (!_utils._.isArray(newValue) && !_utils._.isArray(existingValue)) {
+ // Merge objects (shallow merge with overwrites)
+ result[key] = _utils._.extend({}, existingValue, newValue);
+ } else {
+ // Type mismatch, replace
+ result[key] = newValue;
+ }
+ } else {
+ // For all other cases, replace
+ result[key] = newValue;
+ }
+ }
+ });
+
+ return result;
+};
+
+/**
+ * Clears the auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_clear_timer = function (eventKey) {
+ if (this._heartbeat_timers.has(eventKey)) {
+ clearTimeout(this._heartbeat_timers.get(eventKey));
+ this._heartbeat_timers['delete'](eventKey);
+ this._heartbeat_log('Cleared flush timer for', eventKey);
+ }
+};
+
+/**
+ * Sets up auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_setup_timer = function (eventKey, timeout) {
+ var self = this;
+ try {
+ self._heartbeat_clear_timer(eventKey);
+
+ var timerId = setTimeout(function () {
+ try {
+ self._heartbeat_log('Auto-flushing due to timeout for', eventKey);
+ self._heartbeat_flush_event(eventKey, 'timeout', false);
+ } catch (e) {
+ self.report_error('Error in heartbeat timeout handler: ' + e.message);
+ }
+ }, timeout || 30000);
+
+ this._heartbeat_timers.set(eventKey, timerId);
+ } catch (e) {
+ self.report_error('Error setting up heartbeat timer: ' + e.message);
+ }
+};
+
+/**
+ * Flushes a single heartbeat event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_event = function (eventKey, reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var eventData = storage[eventKey];
+
+ if (!eventData) {
+ return;
+ }
+
+ var eventName = eventData.eventName;
+ var props = eventData.props;
+
+ // Clear any pending timers
+ this._heartbeat_clear_timer(eventKey);
+
+ // Prepare tracking properties (exclude old contentId property)
+ var trackingProps = _utils._.extend({}, props);
+ delete trackingProps.contentId;
+
+ // Prepare transport options
+ var transportOptions = useSendBeacon ? { transport: 'sendBeacon' } : {};
+
+ try {
+ this.track(eventName, trackingProps, transportOptions);
+ this._heartbeat_log('Flushed event', eventKey, 'reason:', reason, 'props:', trackingProps);
+ } catch (error) {
+ this.report_error('Error flushing heartbeat event: ' + error.message);
+ }
+
+ // Remove from storage after flushing
+ delete storage[eventKey];
+ this._heartbeat_save_storage(storage);
+
+ // Clean up event tracking state
+ this._heartbeat_manual_events['delete'](eventKey);
+ this._heartbeat_managed_events['delete'](eventKey);
+};
+
+/**
+ * Flushes all heartbeat events
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_all = function (reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var keys = Object.keys(storage);
+
+ this._heartbeat_log('Flushing all heartbeat events, count:', keys.length, 'reason:', reason);
+
+ for (var i = 0; i < keys.length; i++) {
+ this._heartbeat_flush_event(keys[i], reason, useSendBeacon);
+ }
+};
+
+/**
+ * Internal heartbeat logic (used by both manual and managed APIs)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_internal = function (eventName, contentId, props, options) {
+ var eventKey = eventName + '|' + contentId;
+ this._heartbeat_log('Heartbeat called for', eventKey, 'props:', props);
+
+ // Get current storage
+ var storage = this._heartbeat_get_storage();
+
+ // Check storage size limit (hardcoded to 500)
+ var storageKeys = Object.keys(storage);
+ if (storageKeys.length >= 500 && !(eventKey in storage)) {
+ this.report_error('heartbeat: Maximum storage size reached, flushing oldest event');
+ // Flush the first (oldest) event to make room
+ var oldestKey = storageKeys[0];
+ this._heartbeat_flush_event(oldestKey, 'maxStorageSize', false);
+ storage = this._heartbeat_get_storage(); // Refresh storage after flush
+ }
+
+ var currentTime = new Date().getTime();
+
+ // Get or create event data
+ if (storage[eventKey]) {
+ // Aggregate with existing data
+ var existingData = storage[eventKey];
+ var aggregatedProps = this._heartbeat_aggregate_props(existingData.props, props);
+
+ // Update automatic tracking properties
+ var durationSeconds = Math.round((currentTime - existingData.firstCall) / 1000);
+ aggregatedProps['$duration'] = durationSeconds;
+ aggregatedProps['$heartbeats'] = (existingData.hitCount || 1) + 1;
+ aggregatedProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: aggregatedProps,
+ lastUpdate: currentTime,
+ firstCall: existingData.firstCall,
+ hitCount: (existingData.hitCount || 1) + 1
+ };
+
+ this._heartbeat_log('Aggregated props for', eventKey, 'new props:', aggregatedProps);
+ } else {
+ // Create new entry
+ var newProps = _utils._.extend({}, props);
+ newProps['$duration'] = 0;
+ newProps['$heartbeats'] = 1;
+ newProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: newProps,
+ lastUpdate: currentTime,
+ firstCall: currentTime,
+ hitCount: 1
+ };
+
+ this._heartbeat_log('Created new heartbeat entry for', eventKey);
+ }
+
+ // Save to persistence
+ this._heartbeat_save_storage(storage);
+
+ // Handle force flush or set up timer (skip timer setup for managed intervals)
+ if (options.forceFlush) {
+ this._heartbeat_log('Force flushing requested');
+ this._heartbeat_flush_event(eventKey, 'forceFlush', false);
+ } else if (!options._managed) {
+ // Set up or reset the auto-flush timer with custom timeout (only for manual heartbeats)
+ var timeout = options.timeout || 30000; // Default 30 seconds
+ this._heartbeat_setup_timer(eventKey, timeout);
+ }
+
+ return;
+};
+
+/**
+ * Main heartbeat implementation (public API)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_impl = (0, _gdprUtils.addOptOutCheckMixpanelLib)(function (eventName, contentId, props, options) {
+
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent manual heartbeat() calls on start/stop managed events
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat: Cannot call heartbeat() on an event managed by heartbeat.start(). Use heartbeat.stop() first.');
+ return;
+ }
+
+ // Track this as a manual heartbeat event
+ this._heartbeat_manual_events.add(eventKey);
+
+ // Call the internal implementation
+ this._heartbeat_internal(eventName, contentId, props, options);
+
+ return;
+});
+
+/**
+ * Start implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_start_impl = (0, _gdprUtils.addOptOutCheckMixpanelLib)(function (eventName, contentId, props, options) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.start: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent start() calls on manual heartbeat events
+ if (this._heartbeat_manual_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Cannot start managed heartbeat on an event already using manual heartbeat() calls. Stop calling heartbeat() first.');
+ return;
+ }
+
+ // Check if already started - warn and restart with new params
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Event already started, restarting with new parameters');
+ this._heartbeat_stop_impl(eventName, contentId);
+ }
+
+ // Track this as a managed heartbeat event
+ this._heartbeat_managed_events.add(eventKey);
+
+ var interval = options.interval || 5000; // Default 5 seconds
+
+ // Validate interval parameter to prevent performance issues
+ if (typeof interval !== 'number' || interval < 100) {
+ this.report_error('heartbeat.start: interval must be a number >= 100ms, using default 5000ms');
+ interval = 5000;
+ }
+ if (interval > 300000) {
+ // 5 minutes max
+ this.report_error('heartbeat.start: interval too large, using maximum 300000ms');
+ interval = 300000;
+ }
+
+ var self = this;
+
+ this._heartbeat_log('Starting managed heartbeat for', eventKey, 'interval:', interval + 'ms');
+
+ // Start the interval
+ var intervalId = setInterval(function () {
+ // Call the internal heartbeat implementation with managed flag to skip timer setup
+ self._heartbeat_internal(eventName, contentId, props, { timeout: 30000, _managed: true });
+ }, interval);
+
+ // Store the interval ID
+ this._heartbeat_intervals.set(eventKey, intervalId);
+
+ return;
+});
+
+/**
+ * Stop implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_stop_impl = (0, _gdprUtils.addOptOutCheckMixpanelLib)(function (eventName, contentId) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.stop: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+
+ var eventKey = eventName + '|' + contentId;
+
+ this._heartbeat_log('Stopping managed heartbeat for', eventKey);
+
+ // Clear the interval if it exists
+ if (this._heartbeat_intervals.has(eventKey)) {
+ clearInterval(this._heartbeat_intervals.get(eventKey));
+ this._heartbeat_intervals['delete'](eventKey);
+ }
+
+ // Remove from managed events tracking
+ this._heartbeat_managed_events['delete'](eventKey);
+
+ // Force flush the event immediately (as per requirements)
+ this._heartbeat_flush_event(eventKey, 'stop', false);
+
+ return;
+});
+
/**
* Register the current user into one/many groups.
*
@@ -22833,6 +23329,22 @@ MixpanelLib.prototype.name_tag = function (name_tag) {
*
* // whether to ignore or respect the web browser's Do Not Track setting
* ignore_dnt: false
+ *
+ * // heartbeat event aggregation settings
+ * // milliseconds to wait before auto-flushing aggregated heartbeat events
+ * heartbeat_max_buffer_time_ms: 30000
+ *
+ * // maximum number of properties per heartbeat event before auto-flush
+ * heartbeat_max_props_count: 1000
+ *
+ * // maximum numeric value for property aggregation before auto-flush
+ * heartbeat_max_aggregated_value: 100000
+ *
+ * // maximum number of events stored in heartbeat queue before auto-flush
+ * heartbeat_max_storage_size: 100
+ *
+ * // enable debug logging for heartbeat events
+ * heartbeat_enable_logging: false
* }
*
*
@@ -23278,7 +23790,7 @@ var override_mp_init_func = function override_mp_init_func() {
// main mixpanel lib already initialized
instance = instances[PRIMARY_INSTANCE_NAME];
} else if (token) {
- // intialize the main mixpanel lib
+ // initialize the main mixpanel lib
instance = create_mplib(token, config, PRIMARY_INSTANCE_NAME);
instance._loaded();
instances[PRIMARY_INSTANCE_NAME] = instance;
diff --git a/examples/umd-webpack/bundle.js b/examples/umd-webpack/bundle.js
index f545e10f..84baf68c 100644
--- a/examples/umd-webpack/bundle.js
+++ b/examples/umd-webpack/bundle.js
@@ -20765,7 +20765,7 @@
if (!this.get_distinct_id()) {
// There is no need to set the distinct id
// or the device id if something was already stored
- // in the persitence
+ // in the persistence
this.register_once({
'distinct_id': DEVICE_ID_PREFIX + uuid,
'$device_id': uuid
@@ -20789,6 +20789,7 @@
this._init_tab_id();
this._check_and_start_session_recording();
+ this._init_heartbeat();
};
/**
@@ -21525,6 +21526,505 @@
return ret;
});
+ /**
+ * Initializes the heartbeat tracking system for the instance
+ * @private
+ */
+ MixpanelLib.prototype._init_heartbeat = function() {
+ var self = this;
+
+ // Internal heartbeat state storage
+ this._heartbeat_timers = new Map();
+ this._heartbeat_storage = {}; // In-memory storage for heartbeat events
+ this._heartbeat_unload_setup = false;
+ // State tracking for start/stop vs manual heartbeat APIs
+ this._heartbeat_intervals = new Map(); // Track active start/stop intervals
+ this._heartbeat_manual_events = new Set(); // Track events managed by manual heartbeat() calls
+ this._heartbeat_managed_events = new Set(); // Track events managed by start/stop
+
+ // Setup page unload handlers once
+ this._setup_heartbeat_unload_handlers();
+
+ /**
+ * Client-side aggregation for streaming analytics events like video watch time,
+ * podcast listen time, or other continuous interactions. Designed to be called
+ * in loops without exploding row counts.
+ *
+ * Heartbeat works by aggregating properties client-side until the event is flushed.
+ * Properties are merged intelligently:
+ * - Numbers are added together
+ * - Strings take the latest value
+ * - Objects are merged (latest overwrites)
+ * - Arrays have elements appended
+ *
+ * Events auto-flush after 30 seconds (configurable) or on page unload.
+ *
+ * Each event automatically tracks:
+ * - $duration: Seconds from first to last heartbeat call
+ * - $heartbeats: Number of heartbeat calls made
+ * - $contentId: The contentId parameter
+ *
+ * @function heartbeat
+ * @memberof mixpanel
+ * @param {String} eventName The name of the event to track
+ * @param {String} contentId Unique identifier for the content being tracked
+ * @param {Object} [props] Properties to aggregate with existing data
+ * @param {Object} [options] Configuration options
+ * @param {Number} [options.timeout] Timeout in milliseconds (default 30000)
+ * @param {Boolean} [options.forceFlush] Force immediate flush after aggregation
+ * @returns {Void}
+ *
+ * @example
+ * // Basic video tracking
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' });
+ * mixpanel.heartbeat('video_watch', 'video_123', { position: 30 });
+ * // After 30 seconds: { quality: 'HD', position: 30, $duration: 30, $heartbeats: 2 }
+ *
+ * @example
+ * // Force immediate flush
+ * mixpanel.heartbeat('podcast_listen', 'episode_123', { platform: 'mobile' }, { forceFlush: true });
+ *
+ * @example
+ * // Custom timeout (60 seconds)
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' }, { timeout: 60000 });
+ */
+ this.heartbeat = function(eventName, contentId, props, options) {
+ return self._heartbeat_impl(eventName, contentId, props, options);
+ };
+
+ // Add start/stop methods to the heartbeat function
+ this.heartbeat.start = function(eventName, contentId, props, options) {
+ return self._heartbeat_start_impl(eventName, contentId, props, options);
+ };
+
+ this.heartbeat.stop = function(eventName, contentId) {
+ return self._heartbeat_stop_impl(eventName, contentId);
+ };
+
+ };
+
+ /**
+ * Sets up page unload handlers for heartbeat auto-flush
+ * @private
+ */
+ MixpanelLib.prototype._setup_heartbeat_unload_handlers = function() {
+ if (this._heartbeat_unload_setup) {
+ return;
+ }
+ this._heartbeat_unload_setup = true;
+
+ var self = this;
+ var hasUnloaded = false;
+ var handleUnload = function() {
+ if (hasUnloaded) return;
+ hasUnloaded = true;
+ self._heartbeat_log('Page unload detected, flushing all heartbeat events');
+ self._heartbeat_flush_all('pageUnload', true);
+ };
+
+ // Multiple event handlers for cross-browser compatibility
+ if (win.addEventListener) {
+ win.addEventListener('beforeunload', handleUnload);
+ win.addEventListener('pagehide', handleUnload);
+ win.addEventListener('visibilitychange', function() {
+ if (document$1.visibilityState === 'hidden') {
+ handleUnload();
+ }
+ });
+ }
+ };
+
+ /**
+ * Gets heartbeat event storage from memory
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_get_storage = function() {
+ return this._heartbeat_storage || {};
+ };
+
+ /**
+ * Saves heartbeat events to memory
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_save_storage = function(data) {
+ this._heartbeat_storage = data;
+ };
+
+
+ /**
+ * Logs heartbeat debug messages if logging is enabled
+ * Logs when either global debug is true
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_log = function() {
+ var globalDebugEnabled = this.get_config('debug');
+ if (globalDebugEnabled) {
+ var args = Array.prototype.slice.call(arguments);
+ args[0] = '[Mixpanel Heartbeat] ' + args[0];
+ try {
+ if (typeof win !== 'undefined' && win.console && win.console.log) {
+ win.console.log.apply(win.console, args);
+ }
+ } catch (err) {
+ _.each(args, function(arg) {
+ if (typeof win !== 'undefined' && win.console && win.console.log) {
+ win.console.log(arg);
+ }
+ });
+ }
+ }
+ };
+
+ /**
+ * Aggregates properties according to heartbeat rules
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_aggregate_props = function(existingProps, newProps) {
+ var result = _.extend({}, existingProps);
+ // Remove legacy contentId property in favor of $contentId
+ delete result.contentId;
+
+ _.each(newProps, function(newValue, key) {
+ if (!(key in result)) {
+ result[key] = newValue;
+ } else {
+ var existingValue = result[key];
+ var newType = typeof newValue;
+ var existingType = typeof existingValue;
+
+ if (newType === 'number' && existingType === 'number') {
+ // Add numbers together
+ result[key] = existingValue + newValue;
+ } else if (newType === 'string') {
+ // Replace with new string
+ result[key] = newValue;
+ } else if (newType === 'object' && existingType === 'object') {
+ if (_.isArray(newValue) && _.isArray(existingValue)) {
+ // Concatenate arrays
+ result[key] = existingValue.concat(newValue);
+ } else if (!_.isArray(newValue) && !_.isArray(existingValue)) {
+ // Merge objects (shallow merge with overwrites)
+ result[key] = _.extend({}, existingValue, newValue);
+ } else {
+ // Type mismatch, replace
+ result[key] = newValue;
+ }
+ } else {
+ // For all other cases, replace
+ result[key] = newValue;
+ }
+ }
+ });
+
+ return result;
+ };
+
+
+ /**
+ * Clears the auto-flush timer for a specific event
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_clear_timer = function(eventKey) {
+ if (this._heartbeat_timers.has(eventKey)) {
+ clearTimeout(this._heartbeat_timers.get(eventKey));
+ this._heartbeat_timers.delete(eventKey);
+ this._heartbeat_log('Cleared flush timer for', eventKey);
+ }
+ };
+
+ /**
+ * Sets up auto-flush timer for a specific event
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_setup_timer = function(eventKey, timeout) {
+ var self = this;
+ try {
+ self._heartbeat_clear_timer(eventKey);
+
+ var timerId = setTimeout(function() {
+ try {
+ self._heartbeat_log('Auto-flushing due to timeout for', eventKey);
+ self._heartbeat_flush_event(eventKey, 'timeout', false);
+ } catch (e) {
+ self.report_error('Error in heartbeat timeout handler: ' + e.message);
+ }
+ }, timeout || 30000);
+
+ this._heartbeat_timers.set(eventKey, timerId);
+ } catch (e) {
+ self.report_error('Error setting up heartbeat timer: ' + e.message);
+ }
+ };
+
+ /**
+ * Flushes a single heartbeat event
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_flush_event = function(eventKey, reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var eventData = storage[eventKey];
+
+ if (!eventData) {
+ return;
+ }
+
+ var eventName = eventData.eventName;
+ var props = eventData.props;
+
+ // Clear any pending timers
+ this._heartbeat_clear_timer(eventKey);
+
+ // Prepare tracking properties (exclude old contentId property)
+ var trackingProps = _.extend({}, props);
+ delete trackingProps.contentId;
+
+ // Prepare transport options
+ var transportOptions = useSendBeacon ? { transport: 'sendBeacon' } : {};
+
+ try {
+ this.track(eventName, trackingProps, transportOptions);
+ this._heartbeat_log('Flushed event', eventKey, 'reason:', reason, 'props:', trackingProps);
+ } catch (error) {
+ this.report_error('Error flushing heartbeat event: ' + error.message);
+ }
+
+ // Remove from storage after flushing
+ delete storage[eventKey];
+ this._heartbeat_save_storage(storage);
+
+ // Clean up event tracking state
+ this._heartbeat_manual_events.delete(eventKey);
+ this._heartbeat_managed_events.delete(eventKey);
+
+ };
+
+ /**
+ * Flushes all heartbeat events
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_flush_all = function(reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var keys = Object.keys(storage);
+
+ this._heartbeat_log('Flushing all heartbeat events, count:', keys.length, 'reason:', reason);
+
+ for (var i = 0; i < keys.length; i++) {
+ this._heartbeat_flush_event(keys[i], reason, useSendBeacon);
+ }
+ };
+
+ /**
+ * Internal heartbeat logic (used by both manual and managed APIs)
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_internal = function(eventName, contentId, props, options) {
+ var eventKey = eventName + '|' + contentId;
+ this._heartbeat_log('Heartbeat called for', eventKey, 'props:', props);
+
+ // Get current storage
+ var storage = this._heartbeat_get_storage();
+
+ // Check storage size limit (hardcoded to 500)
+ var storageKeys = Object.keys(storage);
+ if (storageKeys.length >= 500 && !(eventKey in storage)) {
+ this.report_error('heartbeat: Maximum storage size reached, flushing oldest event');
+ // Flush the first (oldest) event to make room
+ var oldestKey = storageKeys[0];
+ this._heartbeat_flush_event(oldestKey, 'maxStorageSize', false);
+ storage = this._heartbeat_get_storage(); // Refresh storage after flush
+ }
+
+ var currentTime = new Date().getTime();
+
+ // Get or create event data
+ if (storage[eventKey]) {
+ // Aggregate with existing data
+ var existingData = storage[eventKey];
+ var aggregatedProps = this._heartbeat_aggregate_props(existingData.props, props);
+
+ // Update automatic tracking properties
+ var durationSeconds = Math.round((currentTime - existingData.firstCall) / 1000);
+ aggregatedProps['$duration'] = durationSeconds;
+ aggregatedProps['$heartbeats'] = (existingData.hitCount || 1) + 1;
+ aggregatedProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: aggregatedProps,
+ lastUpdate: currentTime,
+ firstCall: existingData.firstCall,
+ hitCount: (existingData.hitCount || 1) + 1
+ };
+
+ this._heartbeat_log('Aggregated props for', eventKey, 'new props:', aggregatedProps);
+ } else {
+ // Create new entry
+ var newProps = _.extend({}, props);
+ newProps['$duration'] = 0;
+ newProps['$heartbeats'] = 1;
+ newProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ contentId: contentId,
+ props: newProps,
+ lastUpdate: currentTime,
+ firstCall: currentTime,
+ hitCount: 1
+ };
+
+ this._heartbeat_log('Created new heartbeat entry for', eventKey);
+ }
+
+ // Save to persistence
+ this._heartbeat_save_storage(storage);
+
+ // Handle force flush or set up timer (skip timer setup for managed intervals)
+ if (options.forceFlush) {
+ this._heartbeat_log('Force flushing requested');
+ this._heartbeat_flush_event(eventKey, 'forceFlush', false);
+ } else if (!options._managed) {
+ // Set up or reset the auto-flush timer with custom timeout (only for manual heartbeats)
+ var timeout = options.timeout || 30000; // Default 30 seconds
+ this._heartbeat_setup_timer(eventKey, timeout);
+ }
+
+ return;
+ };
+
+ /**
+ * Main heartbeat implementation (public API)
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent manual heartbeat() calls on start/stop managed events
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat: Cannot call heartbeat() on an event managed by heartbeat.start(). Use heartbeat.stop() first.');
+ return;
+ }
+
+ // Track this as a manual heartbeat event
+ this._heartbeat_manual_events.add(eventKey);
+
+ // Call the internal implementation
+ this._heartbeat_internal(eventName, contentId, props, options);
+
+ return;
+ });
+
+ /**
+ * Start implementation for managed heartbeat intervals
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_start_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.start: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ // API separation: prevent start() calls on manual heartbeat events
+ if (this._heartbeat_manual_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Cannot start managed heartbeat on an event already using manual heartbeat() calls. Stop calling heartbeat() first.');
+ return;
+ }
+
+ // Check if already started - warn and restart with new params
+ if (this._heartbeat_managed_events.has(eventKey)) {
+ this.report_error('heartbeat.start: Event already started, restarting with new parameters');
+ this._heartbeat_stop_impl(eventName, contentId);
+ }
+
+ // Track this as a managed heartbeat event
+ this._heartbeat_managed_events.add(eventKey);
+
+ var interval = options.interval || 5000; // Default 5 seconds
+
+ // Validate interval parameter to prevent performance issues
+ if (typeof interval !== 'number' || interval < 100) {
+ this.report_error('heartbeat.start: interval must be a number >= 100ms, using default 5000ms');
+ interval = 5000;
+ }
+ if (interval > 300000) { // 5 minutes max
+ this.report_error('heartbeat.start: interval too large, using maximum 300000ms');
+ interval = 300000;
+ }
+
+ var self = this;
+
+ this._heartbeat_log('Starting managed heartbeat for', eventKey, 'interval:', interval + 'ms');
+
+ // Start the interval
+ var intervalId = setInterval(function() {
+ // Call the internal heartbeat implementation with managed flag to skip timer setup
+ self._heartbeat_internal(eventName, contentId, props, { timeout: 30000, _managed: true });
+ }, interval);
+
+ // Store the interval ID
+ this._heartbeat_intervals.set(eventKey, intervalId);
+
+ return;
+ });
+
+ /**
+ * Stop implementation for managed heartbeat intervals
+ * @private
+ */
+ MixpanelLib.prototype._heartbeat_stop_impl = addOptOutCheckMixpanelLib(function(eventName, contentId) {
+ // Validate required parameters
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.stop: eventName and contentId are required');
+ return;
+ }
+
+ // Convert to strings
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+
+ var eventKey = eventName + '|' + contentId;
+
+ this._heartbeat_log('Stopping managed heartbeat for', eventKey);
+
+ // Clear the interval if it exists
+ if (this._heartbeat_intervals.has(eventKey)) {
+ clearInterval(this._heartbeat_intervals.get(eventKey));
+ this._heartbeat_intervals.delete(eventKey);
+ }
+
+ // Remove from managed events tracking
+ this._heartbeat_managed_events.delete(eventKey);
+
+ // Force flush the event immediately (as per requirements)
+ this._heartbeat_flush_event(eventKey, 'stop', false);
+
+ return;
+ });
+
+
/**
* Register the current user into one/many groups.
*
@@ -22259,6 +22759,22 @@
*
* // whether to ignore or respect the web browser's Do Not Track setting
* ignore_dnt: false
+ *
+ * // heartbeat event aggregation settings
+ * // milliseconds to wait before auto-flushing aggregated heartbeat events
+ * heartbeat_max_buffer_time_ms: 30000
+ *
+ * // maximum number of properties per heartbeat event before auto-flush
+ * heartbeat_max_props_count: 1000
+ *
+ * // maximum numeric value for property aggregation before auto-flush
+ * heartbeat_max_aggregated_value: 100000
+ *
+ * // maximum number of events stored in heartbeat queue before auto-flush
+ * heartbeat_max_storage_size: 100
+ *
+ * // enable debug logging for heartbeat events
+ * heartbeat_enable_logging: false
* }
*
*
@@ -22707,7 +23223,7 @@
// main mixpanel lib already initialized
instance = instances[PRIMARY_INSTANCE_NAME];
} else if (token) {
- // intialize the main mixpanel lib
+ // initialize the main mixpanel lib
instance = create_mplib(token, config, PRIMARY_INSTANCE_NAME);
instance._loaded();
instances[PRIMARY_INSTANCE_NAME] = instance;
diff --git a/examples/umd-webpack/package-lock.json b/examples/umd-webpack/package-lock.json
index eb996e6c..c7f7fa3e 100644
--- a/examples/umd-webpack/package-lock.json
+++ b/examples/umd-webpack/package-lock.json
@@ -446,14 +446,14 @@
},
"node_modules/fsevents/node_modules/abbrev": {
"version": "1.1.1",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/ansi-regex": {
"version": "2.1.1",
- "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -461,14 +461,14 @@
},
"node_modules/fsevents/node_modules/aproba": {
"version": "1.2.0",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/are-we-there-yet": {
"version": "1.1.4",
- "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
@@ -477,14 +477,14 @@
},
"node_modules/fsevents/node_modules/balanced-match": {
"version": "1.0.0",
- "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/brace-expansion": {
"version": "1.1.11",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -493,14 +493,14 @@
},
"node_modules/fsevents/node_modules/chownr": {
"version": "1.0.1",
- "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/code-point-at": {
"version": "1.1.0",
- "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -508,26 +508,26 @@
},
"node_modules/fsevents/node_modules/concat-map": {
"version": "0.0.1",
- "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/console-control-strings": {
"version": "1.1.0",
- "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/core-util-is": {
"version": "1.0.2",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/debug": {
"version": "2.6.9",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"ms": "2.0.0"
@@ -535,8 +535,8 @@
},
"node_modules/fsevents/node_modules/deep-extend": {
"version": "0.5.1",
- "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"iojs": ">=1.0.0",
@@ -545,14 +545,14 @@
},
"node_modules/fsevents/node_modules/delegates": {
"version": "1.0.0",
- "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/detect-libc": {
"version": "1.0.3",
- "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
"inBundle": true,
+ "license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
@@ -563,8 +563,8 @@
},
"node_modules/fsevents/node_modules/fs-minipass": {
"version": "1.2.5",
- "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^2.2.1"
@@ -572,14 +572,14 @@
},
"node_modules/fsevents/node_modules/fs.realpath": {
"version": "1.0.0",
- "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/gauge": {
"version": "2.7.4",
- "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"aproba": "^1.0.3",
@@ -594,8 +594,8 @@
},
"node_modules/fsevents/node_modules/glob": {
"version": "7.1.2",
- "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -611,14 +611,14 @@
},
"node_modules/fsevents/node_modules/has-unicode": {
"version": "2.0.1",
- "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/iconv-lite": {
"version": "0.4.21",
- "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": "^2.1.0"
@@ -629,8 +629,8 @@
},
"node_modules/fsevents/node_modules/ignore-walk": {
"version": "3.0.1",
- "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"minimatch": "^3.0.4"
@@ -638,8 +638,8 @@
},
"node_modules/fsevents/node_modules/inflight": {
"version": "1.0.6",
- "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"once": "^1.3.0",
@@ -648,15 +648,14 @@
},
"node_modules/fsevents/node_modules/inherits": {
"version": "2.0.3",
- "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/ini": {
"version": "1.3.5",
- "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
- "deprecated": "Please update to ini >=1.3.6 to avoid a prototype pollution issue",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"engines": {
"node": "*"
@@ -664,8 +663,8 @@
},
"node_modules/fsevents/node_modules/is-fullwidth-code-point": {
"version": "1.0.0",
- "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"number-is-nan": "^1.0.0"
@@ -676,14 +675,14 @@
},
"node_modules/fsevents/node_modules/isarray": {
"version": "1.0.0",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/minimatch": {
"version": "3.0.4",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -694,14 +693,14 @@
},
"node_modules/fsevents/node_modules/minimist": {
"version": "0.0.8",
- "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/minipass": {
"version": "2.2.4",
- "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"safe-buffer": "^5.1.1",
@@ -710,8 +709,8 @@
},
"node_modules/fsevents/node_modules/minizlib": {
"version": "1.1.0",
- "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^2.2.1"
@@ -719,9 +718,8 @@
},
"node_modules/fsevents/node_modules/mkdirp": {
"version": "0.5.1",
- "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
- "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"minimist": "0.0.8"
@@ -732,14 +730,14 @@
},
"node_modules/fsevents/node_modules/ms": {
"version": "2.0.0",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/needle": {
"version": "2.2.0",
- "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"debug": "^2.1.2",
@@ -755,9 +753,8 @@
},
"node_modules/fsevents/node_modules/node-pre-gyp": {
"version": "0.10.0",
- "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
- "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future",
"inBundle": true,
+ "license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.2",
@@ -777,8 +774,8 @@
},
"node_modules/fsevents/node_modules/nopt": {
"version": "4.0.1",
- "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"abbrev": "1",
@@ -790,14 +787,14 @@
},
"node_modules/fsevents/node_modules/npm-bundled": {
"version": "1.0.3",
- "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/npm-packlist": {
"version": "1.1.10",
- "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"ignore-walk": "^3.0.1",
@@ -806,8 +803,8 @@
},
"node_modules/fsevents/node_modules/npmlog": {
"version": "4.1.2",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"are-we-there-yet": "~1.1.2",
@@ -818,8 +815,8 @@
},
"node_modules/fsevents/node_modules/number-is-nan": {
"version": "1.0.1",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -827,8 +824,8 @@
},
"node_modules/fsevents/node_modules/object-assign": {
"version": "4.1.1",
- "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -836,8 +833,8 @@
},
"node_modules/fsevents/node_modules/once": {
"version": "1.4.0",
- "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
@@ -845,8 +842,8 @@
},
"node_modules/fsevents/node_modules/os-homedir": {
"version": "1.0.2",
- "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -854,8 +851,8 @@
},
"node_modules/fsevents/node_modules/os-tmpdir": {
"version": "1.0.2",
- "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -863,8 +860,8 @@
},
"node_modules/fsevents/node_modules/osenv": {
"version": "0.1.5",
- "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"os-homedir": "^1.0.0",
@@ -873,8 +870,8 @@
},
"node_modules/fsevents/node_modules/path-is-absolute": {
"version": "1.0.1",
- "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -882,14 +879,14 @@
},
"node_modules/fsevents/node_modules/process-nextick-args": {
"version": "2.0.0",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/rc": {
"version": "1.2.7",
- "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
"inBundle": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.5.1",
@@ -903,14 +900,14 @@
},
"node_modules/fsevents/node_modules/rc/node_modules/minimist": {
"version": "1.2.0",
- "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/readable-stream": {
"version": "2.3.6",
- "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"core-util-is": "~1.0.0",
@@ -924,8 +921,8 @@
},
"node_modules/fsevents/node_modules/rimraf": {
"version": "2.6.2",
- "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"glob": "^7.0.5"
@@ -936,26 +933,26 @@
},
"node_modules/fsevents/node_modules/safe-buffer": {
"version": "5.1.1",
- "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/safer-buffer": {
"version": "2.1.2",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/sax": {
"version": "1.2.4",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/semver": {
"version": "5.5.0",
- "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver"
@@ -963,20 +960,20 @@
},
"node_modules/fsevents/node_modules/set-blocking": {
"version": "2.0.0",
- "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/signal-exit": {
"version": "3.0.2",
- "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/string_decoder": {
"version": "1.1.1",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.1.0"
@@ -984,8 +981,8 @@
},
"node_modules/fsevents/node_modules/string-width": {
"version": "1.0.2",
- "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"code-point-at": "^1.0.0",
@@ -998,8 +995,8 @@
},
"node_modules/fsevents/node_modules/strip-ansi": {
"version": "3.0.1",
- "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^2.0.0"
@@ -1010,8 +1007,8 @@
},
"node_modules/fsevents/node_modules/strip-json-comments": {
"version": "2.0.1",
- "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -1019,8 +1016,8 @@
},
"node_modules/fsevents/node_modules/tar": {
"version": "4.4.1",
- "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"chownr": "^1.0.1",
@@ -1037,14 +1034,14 @@
},
"node_modules/fsevents/node_modules/util-deprecate": {
"version": "1.0.2",
- "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/wide-align": {
"version": "1.1.2",
- "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^1.0.2"
@@ -1052,14 +1049,14 @@
},
"node_modules/fsevents/node_modules/wrappy": {
"version": "1.0.2",
- "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/yallist": {
"version": "3.0.2",
- "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/glob-base": {
@@ -2241,25 +2238,21 @@
"dependencies": {
"abbrev": {
"version": "1.1.1",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"bundled": true,
"optional": true
},
"ansi-regex": {
"version": "2.1.1",
- "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"bundled": true,
"optional": true
},
"are-we-there-yet": {
"version": "1.1.4",
- "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
"bundled": true,
"optional": true,
"requires": {
@@ -2269,13 +2262,11 @@
},
"balanced-match": {
"version": "1.0.0",
- "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"bundled": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2285,37 +2276,31 @@
},
"chownr": {
"version": "1.0.1",
- "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
"bundled": true,
"optional": true
},
"code-point-at": {
"version": "1.1.0",
- "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"bundled": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
- "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"bundled": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
- "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"bundled": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"bundled": true,
"optional": true
},
"debug": {
"version": "2.6.9",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2324,25 +2309,21 @@
},
"deep-extend": {
"version": "0.5.1",
- "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
"bundled": true,
"optional": true
},
"delegates": {
"version": "1.0.0",
- "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"bundled": true,
"optional": true
},
"detect-libc": {
"version": "1.0.3",
- "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
"bundled": true,
"optional": true
},
"fs-minipass": {
"version": "1.2.5",
- "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"bundled": true,
"optional": true,
"requires": {
@@ -2351,13 +2332,11 @@
},
"fs.realpath": {
"version": "1.0.0",
- "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"bundled": true,
"optional": true
},
"gauge": {
"version": "2.7.4",
- "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
"bundled": true,
"optional": true,
"requires": {
@@ -2373,7 +2352,6 @@
},
"glob": {
"version": "7.1.2",
- "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"bundled": true,
"optional": true,
"requires": {
@@ -2387,13 +2365,11 @@
},
"has-unicode": {
"version": "2.0.1",
- "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
"bundled": true,
"optional": true
},
"iconv-lite": {
"version": "0.4.21",
- "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
"bundled": true,
"optional": true,
"requires": {
@@ -2402,7 +2378,6 @@
},
"ignore-walk": {
"version": "3.0.1",
- "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"bundled": true,
"optional": true,
"requires": {
@@ -2411,7 +2386,6 @@
},
"inflight": {
"version": "1.0.6",
- "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"bundled": true,
"optional": true,
"requires": {
@@ -2421,19 +2395,16 @@
},
"inherits": {
"version": "2.0.3",
- "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"bundled": true,
"optional": true
},
"ini": {
"version": "1.3.5",
- "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"bundled": true,
"optional": true
},
"is-fullwidth-code-point": {
"version": "1.0.0",
- "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"bundled": true,
"optional": true,
"requires": {
@@ -2442,13 +2413,11 @@
},
"isarray": {
"version": "1.0.0",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"bundled": true,
"optional": true
},
"minimatch": {
"version": "3.0.4",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2457,13 +2426,11 @@
},
"minimist": {
"version": "0.0.8",
- "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"bundled": true,
"optional": true
},
"minipass": {
"version": "2.2.4",
- "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"bundled": true,
"optional": true,
"requires": {
@@ -2473,7 +2440,6 @@
},
"minizlib": {
"version": "1.1.0",
- "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2482,7 +2448,6 @@
},
"mkdirp": {
"version": "0.5.1",
- "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"bundled": true,
"optional": true,
"requires": {
@@ -2491,13 +2456,11 @@
},
"ms": {
"version": "2.0.0",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"bundled": true,
"optional": true
},
"needle": {
"version": "2.2.0",
- "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
"bundled": true,
"optional": true,
"requires": {
@@ -2508,7 +2471,6 @@
},
"node-pre-gyp": {
"version": "0.10.0",
- "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
"bundled": true,
"optional": true,
"requires": {
@@ -2526,7 +2488,6 @@
},
"nopt": {
"version": "4.0.1",
- "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
"bundled": true,
"optional": true,
"requires": {
@@ -2536,13 +2497,11 @@
},
"npm-bundled": {
"version": "1.0.3",
- "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
"bundled": true,
"optional": true
},
"npm-packlist": {
"version": "1.1.10",
- "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2552,7 +2511,6 @@
},
"npmlog": {
"version": "4.1.2",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"bundled": true,
"optional": true,
"requires": {
@@ -2564,19 +2522,16 @@
},
"number-is-nan": {
"version": "1.0.1",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"bundled": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
- "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"bundled": true,
"optional": true
},
"once": {
"version": "1.4.0",
- "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"bundled": true,
"optional": true,
"requires": {
@@ -2585,19 +2540,16 @@
},
"os-homedir": {
"version": "1.0.2",
- "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"bundled": true,
"optional": true
},
"os-tmpdir": {
"version": "1.0.2",
- "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"bundled": true,
"optional": true
},
"osenv": {
"version": "0.1.5",
- "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"bundled": true,
"optional": true,
"requires": {
@@ -2607,19 +2559,16 @@
},
"path-is-absolute": {
"version": "1.0.1",
- "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"bundled": true,
"optional": true
},
"process-nextick-args": {
"version": "2.0.0",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"bundled": true,
"optional": true
},
"rc": {
"version": "1.2.7",
- "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
"bundled": true,
"optional": true,
"requires": {
@@ -2631,7 +2580,6 @@
"dependencies": {
"minimist": {
"version": "1.2.0",
- "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"bundled": true,
"optional": true
}
@@ -2639,7 +2587,6 @@
},
"readable-stream": {
"version": "2.3.6",
- "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"bundled": true,
"optional": true,
"requires": {
@@ -2654,7 +2601,6 @@
},
"rimraf": {
"version": "2.6.2",
- "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
"bundled": true,
"optional": true,
"requires": {
@@ -2663,43 +2609,36 @@
},
"safe-buffer": {
"version": "5.1.1",
- "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"bundled": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"bundled": true,
"optional": true
},
"sax": {
"version": "1.2.4",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"bundled": true,
"optional": true
},
"semver": {
"version": "5.5.0",
- "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"bundled": true,
"optional": true
},
"set-blocking": {
"version": "2.0.0",
- "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"bundled": true,
"optional": true
},
"signal-exit": {
"version": "3.0.2",
- "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"bundled": true,
"optional": true
},
"string_decoder": {
"version": "1.1.1",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"bundled": true,
"optional": true,
"requires": {
@@ -2708,7 +2647,6 @@
},
"string-width": {
"version": "1.0.2",
- "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"bundled": true,
"optional": true,
"requires": {
@@ -2719,7 +2657,6 @@
},
"strip-ansi": {
"version": "3.0.1",
- "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"bundled": true,
"optional": true,
"requires": {
@@ -2728,13 +2665,11 @@
},
"strip-json-comments": {
"version": "2.0.1",
- "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"bundled": true,
"optional": true
},
"tar": {
"version": "4.4.1",
- "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"bundled": true,
"optional": true,
"requires": {
@@ -2749,13 +2684,11 @@
},
"util-deprecate": {
"version": "1.0.2",
- "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"bundled": true,
"optional": true
},
"wide-align": {
"version": "1.1.2",
- "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
"bundled": true,
"optional": true,
"requires": {
@@ -2764,13 +2697,11 @@
},
"wrappy": {
"version": "1.0.2",
- "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"bundled": true,
"optional": true
},
"yallist": {
"version": "3.0.2",
- "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"bundled": true,
"optional": true
}
diff --git a/package-lock.json b/package-lock.json
index 58a5b215..91eedb7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6354,16 +6354,16 @@
},
"node_modules/fsevents/node_modules/abbrev": {
"version": "1.1.1",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/ansi-regex": {
"version": "2.1.1",
- "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6371,16 +6371,16 @@
},
"node_modules/fsevents/node_modules/aproba": {
"version": "1.2.0",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/are-we-there-yet": {
"version": "1.1.4",
- "integrity": "sha512-QbMPI8teYlZBIBqDgmIWfDKO149dGtQV2ium8WniCaARFFRd1e+IES1LA4sSGcVTFdVL628+163WUbxev7R/aQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
@@ -6389,16 +6389,16 @@
},
"node_modules/fsevents/node_modules/balanced-match": {
"version": "1.0.0",
- "integrity": "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/brace-expansion": {
"version": "1.1.11",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -6407,16 +6407,16 @@
},
"node_modules/fsevents/node_modules/chownr": {
"version": "1.0.1",
- "integrity": "sha512-cKnqUJAC8G6cuN1DiRRTifu+s1BlAQNtalzGphFEV0pl0p46dsxJD4l1AOlyKJeLZOFzo3c34R7F3djxaCu8Kw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/code-point-at": {
"version": "1.1.0",
- "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6424,30 +6424,30 @@
},
"node_modules/fsevents/node_modules/concat-map": {
"version": "0.0.1",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/console-control-strings": {
"version": "1.1.0",
- "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/core-util-is": {
"version": "1.0.2",
- "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/debug": {
"version": "2.6.9",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"ms": "2.0.0"
@@ -6455,9 +6455,9 @@
},
"node_modules/fsevents/node_modules/deep-extend": {
"version": "0.5.1",
- "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"iojs": ">=1.0.0",
@@ -6466,16 +6466,16 @@
},
"node_modules/fsevents/node_modules/delegates": {
"version": "1.0.0",
- "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/detect-libc": {
"version": "1.0.3",
- "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"inBundle": true,
+ "license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
@@ -6486,9 +6486,9 @@
},
"node_modules/fsevents/node_modules/fs-minipass": {
"version": "1.2.5",
- "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^2.2.1"
@@ -6496,16 +6496,16 @@
},
"node_modules/fsevents/node_modules/fs.realpath": {
"version": "1.0.0",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/gauge": {
"version": "2.7.4",
- "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"aproba": "^1.0.3",
@@ -6520,9 +6520,9 @@
},
"node_modules/fsevents/node_modules/glob": {
"version": "7.1.2",
- "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -6538,16 +6538,16 @@
},
"node_modules/fsevents/node_modules/has-unicode": {
"version": "2.0.1",
- "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/iconv-lite": {
"version": "0.4.21",
- "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": "^2.1.0"
@@ -6558,9 +6558,9 @@
},
"node_modules/fsevents/node_modules/ignore-walk": {
"version": "3.0.1",
- "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"minimatch": "^3.0.4"
@@ -6568,9 +6568,9 @@
},
"node_modules/fsevents/node_modules/inflight": {
"version": "1.0.6",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"once": "^1.3.0",
@@ -6579,16 +6579,26 @@
},
"node_modules/fsevents/node_modules/inherits": {
"version": "2.0.3",
- "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
+ "node_modules/fsevents/node_modules/ini": {
+ "version": "1.3.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "optional": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/fsevents/node_modules/is-fullwidth-code-point": {
"version": "1.0.0",
- "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"number-is-nan": "^1.0.0"
@@ -6599,16 +6609,16 @@
},
"node_modules/fsevents/node_modules/isarray": {
"version": "1.0.0",
- "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/minimatch": {
"version": "3.0.4",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -6619,16 +6629,16 @@
},
"node_modules/fsevents/node_modules/minimist": {
"version": "0.0.8",
- "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/minipass": {
"version": "2.2.4",
- "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"safe-buffer": "^5.1.1",
@@ -6637,9 +6647,9 @@
},
"node_modules/fsevents/node_modules/minizlib": {
"version": "1.1.0",
- "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^2.2.1"
@@ -6647,10 +6657,9 @@
},
"node_modules/fsevents/node_modules/mkdirp": {
"version": "0.5.1",
- "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
- "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"minimist": "0.0.8"
@@ -6661,16 +6670,16 @@
},
"node_modules/fsevents/node_modules/ms": {
"version": "2.0.0",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/needle": {
"version": "2.2.0",
- "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"debug": "^2.1.2",
@@ -6686,10 +6695,9 @@
},
"node_modules/fsevents/node_modules/node-pre-gyp": {
"version": "0.10.0",
- "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
- "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future",
"dev": true,
"inBundle": true,
+ "license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.2",
@@ -6709,9 +6717,9 @@
},
"node_modules/fsevents/node_modules/nopt": {
"version": "4.0.1",
- "integrity": "sha512-+5XZFpQZEY0cg5JaxLwGxDlKNKYxuXwGt8/Oi3UXm5/4ymrJve9d2CURituxv3rSrVCGZj4m1U1JlHTdcKt2Ng==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"abbrev": "1",
@@ -6723,16 +6731,16 @@
},
"node_modules/fsevents/node_modules/npm-bundled": {
"version": "1.0.3",
- "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/npm-packlist": {
"version": "1.1.10",
- "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"ignore-walk": "^3.0.1",
@@ -6741,9 +6749,9 @@
},
"node_modules/fsevents/node_modules/npmlog": {
"version": "4.1.2",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"are-we-there-yet": "~1.1.2",
@@ -6754,9 +6762,9 @@
},
"node_modules/fsevents/node_modules/number-is-nan": {
"version": "1.0.1",
- "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6764,9 +6772,9 @@
},
"node_modules/fsevents/node_modules/object-assign": {
"version": "4.1.1",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6774,9 +6782,9 @@
},
"node_modules/fsevents/node_modules/once": {
"version": "1.4.0",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
@@ -6784,9 +6792,9 @@
},
"node_modules/fsevents/node_modules/os-homedir": {
"version": "1.0.2",
- "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6794,9 +6802,9 @@
},
"node_modules/fsevents/node_modules/os-tmpdir": {
"version": "1.0.2",
- "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6804,9 +6812,9 @@
},
"node_modules/fsevents/node_modules/osenv": {
"version": "0.1.5",
- "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"os-homedir": "^1.0.0",
@@ -6815,9 +6823,9 @@
},
"node_modules/fsevents/node_modules/path-is-absolute": {
"version": "1.0.1",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6825,16 +6833,16 @@
},
"node_modules/fsevents/node_modules/process-nextick-args": {
"version": "2.0.0",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/rc": {
"version": "1.2.7",
- "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
"dev": true,
"inBundle": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.5.1",
@@ -6848,16 +6856,16 @@
},
"node_modules/fsevents/node_modules/rc/node_modules/minimist": {
"version": "1.2.0",
- "integrity": "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/readable-stream": {
"version": "2.3.6",
- "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"core-util-is": "~1.0.0",
@@ -6871,9 +6879,9 @@
},
"node_modules/fsevents/node_modules/rimraf": {
"version": "2.6.2",
- "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"glob": "^7.0.5"
@@ -6884,30 +6892,30 @@
},
"node_modules/fsevents/node_modules/safe-buffer": {
"version": "5.1.1",
- "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/safer-buffer": {
"version": "2.1.2",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/sax": {
"version": "1.2.4",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/semver": {
"version": "5.5.0",
- "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver"
@@ -6915,23 +6923,23 @@
},
"node_modules/fsevents/node_modules/set-blocking": {
"version": "2.0.0",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/signal-exit": {
"version": "3.0.2",
- "integrity": "sha512-meQNNykwecVxdu1RlYMKpQx4+wefIYpmxi6gexo/KAbwquJrBUrBmKYJrE8KFkVQAAVWEnwNdu21PgrD77J3xA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/string_decoder": {
"version": "1.1.1",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.1.0"
@@ -6939,9 +6947,9 @@
},
"node_modules/fsevents/node_modules/string-width": {
"version": "1.0.2",
- "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"code-point-at": "^1.0.0",
@@ -6954,9 +6962,9 @@
},
"node_modules/fsevents/node_modules/strip-ansi": {
"version": "3.0.1",
- "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^2.0.0"
@@ -6967,9 +6975,9 @@
},
"node_modules/fsevents/node_modules/strip-json-comments": {
"version": "2.0.1",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
@@ -6977,9 +6985,9 @@
},
"node_modules/fsevents/node_modules/tar": {
"version": "4.4.1",
- "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"chownr": "^1.0.1",
@@ -6996,16 +7004,16 @@
},
"node_modules/fsevents/node_modules/util-deprecate": {
"version": "1.0.2",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"inBundle": true,
+ "license": "MIT",
"optional": true
},
"node_modules/fsevents/node_modules/wide-align": {
"version": "1.1.2",
- "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^1.0.2"
@@ -7013,16 +7021,16 @@
},
"node_modules/fsevents/node_modules/wrappy": {
"version": "1.0.2",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/fsevents/node_modules/yallist": {
"version": "3.0.2",
- "integrity": "sha512-U+iKQ8rDYMRmvEpvDUIWZ3CtM9/imlAc+c1yJ7YV0vu+HNtP82sAkXzuDXPLkIPoLZohnXFSs9wf2E17xk5yZA==",
"dev": true,
"inBundle": true,
+ "license": "ISC",
"optional": true
},
"node_modules/function-bind": {
@@ -19602,28 +19610,24 @@
"dependencies": {
"abbrev": {
"version": "1.1.1",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"bundled": true,
"dev": true,
"optional": true
},
"ansi-regex": {
"version": "2.1.1",
- "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"bundled": true,
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"bundled": true,
"dev": true,
"optional": true
},
"are-we-there-yet": {
"version": "1.1.4",
- "integrity": "sha512-QbMPI8teYlZBIBqDgmIWfDKO149dGtQV2ium8WniCaARFFRd1e+IES1LA4sSGcVTFdVL628+163WUbxev7R/aQ==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19634,14 +19638,12 @@
},
"balanced-match": {
"version": "1.0.0",
- "integrity": "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==",
"bundled": true,
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19652,42 +19654,36 @@
},
"chownr": {
"version": "1.0.1",
- "integrity": "sha512-cKnqUJAC8G6cuN1DiRRTifu+s1BlAQNtalzGphFEV0pl0p46dsxJD4l1AOlyKJeLZOFzo3c34R7F3djxaCu8Kw==",
"bundled": true,
"dev": true,
"optional": true
},
"code-point-at": {
"version": "1.1.0",
- "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
"bundled": true,
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"bundled": true,
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
- "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"bundled": true,
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
- "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"bundled": true,
"dev": true,
"optional": true
},
"debug": {
"version": "2.6.9",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19697,28 +19693,24 @@
},
"deep-extend": {
"version": "0.5.1",
- "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
"bundled": true,
"dev": true,
"optional": true
},
"delegates": {
"version": "1.0.0",
- "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"bundled": true,
"dev": true,
"optional": true
},
"detect-libc": {
"version": "1.0.3",
- "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"bundled": true,
"dev": true,
"optional": true
},
"fs-minipass": {
"version": "1.2.5",
- "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19728,14 +19720,12 @@
},
"fs.realpath": {
"version": "1.0.0",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"bundled": true,
"dev": true,
"optional": true
},
"gauge": {
"version": "2.7.4",
- "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19752,7 +19742,6 @@
},
"glob": {
"version": "7.1.2",
- "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19767,14 +19756,12 @@
},
"has-unicode": {
"version": "2.0.1",
- "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"bundled": true,
"dev": true,
"optional": true
},
"iconv-lite": {
"version": "0.4.21",
- "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19784,7 +19771,6 @@
},
"ignore-walk": {
"version": "3.0.1",
- "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19794,7 +19780,6 @@
},
"inflight": {
"version": "1.0.6",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19805,14 +19790,18 @@
},
"inherits": {
"version": "2.0.3",
- "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "bundled": true,
+ "dev": true,
+ "optional": true
+ },
+ "ini": {
+ "version": "1.3.5",
"bundled": true,
"dev": true,
"optional": true
},
"is-fullwidth-code-point": {
"version": "1.0.0",
- "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19822,14 +19811,12 @@
},
"isarray": {
"version": "1.0.0",
- "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"bundled": true,
"dev": true,
"optional": true
},
"minimatch": {
"version": "3.0.4",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19839,14 +19826,12 @@
},
"minimist": {
"version": "0.0.8",
- "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==",
"bundled": true,
"dev": true,
"optional": true
},
"minipass": {
"version": "2.2.4",
- "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19857,7 +19842,6 @@
},
"minizlib": {
"version": "1.1.0",
- "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19867,7 +19851,6 @@
},
"mkdirp": {
"version": "0.5.1",
- "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19877,14 +19860,12 @@
},
"ms": {
"version": "2.0.0",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"bundled": true,
"dev": true,
"optional": true
},
"needle": {
"version": "2.2.0",
- "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19896,7 +19877,6 @@
},
"node-pre-gyp": {
"version": "0.10.0",
- "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19915,7 +19895,6 @@
},
"nopt": {
"version": "4.0.1",
- "integrity": "sha512-+5XZFpQZEY0cg5JaxLwGxDlKNKYxuXwGt8/Oi3UXm5/4ymrJve9d2CURituxv3rSrVCGZj4m1U1JlHTdcKt2Ng==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19926,14 +19905,12 @@
},
"npm-bundled": {
"version": "1.0.3",
- "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
"bundled": true,
"dev": true,
"optional": true
},
"npm-packlist": {
"version": "1.1.10",
- "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19944,7 +19921,6 @@
},
"npmlog": {
"version": "4.1.2",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19957,21 +19933,18 @@
},
"number-is-nan": {
"version": "1.0.1",
- "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
"bundled": true,
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"bundled": true,
"dev": true,
"optional": true
},
"once": {
"version": "1.4.0",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"bundled": true,
"dev": true,
"optional": true,
@@ -19981,21 +19954,18 @@
},
"os-homedir": {
"version": "1.0.2",
- "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
"bundled": true,
"dev": true,
"optional": true
},
"os-tmpdir": {
"version": "1.0.2",
- "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"bundled": true,
"dev": true,
"optional": true
},
"osenv": {
"version": "0.1.5",
- "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20006,21 +19976,18 @@
},
"path-is-absolute": {
"version": "1.0.1",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"bundled": true,
"dev": true,
"optional": true
},
"process-nextick-args": {
"version": "2.0.0",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"bundled": true,
"dev": true,
"optional": true
},
"rc": {
"version": "1.2.7",
- "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20033,7 +20000,6 @@
"dependencies": {
"minimist": {
"version": "1.2.0",
- "integrity": "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==",
"bundled": true,
"dev": true,
"optional": true
@@ -20042,7 +20008,6 @@
},
"readable-stream": {
"version": "2.3.6",
- "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20058,7 +20023,6 @@
},
"rimraf": {
"version": "2.6.2",
- "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20068,49 +20032,42 @@
},
"safe-buffer": {
"version": "5.1.1",
- "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"bundled": true,
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"bundled": true,
"dev": true,
"optional": true
},
"sax": {
"version": "1.2.4",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"bundled": true,
"dev": true,
"optional": true
},
"semver": {
"version": "5.5.0",
- "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"bundled": true,
"dev": true,
"optional": true
},
"set-blocking": {
"version": "2.0.0",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"bundled": true,
"dev": true,
"optional": true
},
"signal-exit": {
"version": "3.0.2",
- "integrity": "sha512-meQNNykwecVxdu1RlYMKpQx4+wefIYpmxi6gexo/KAbwquJrBUrBmKYJrE8KFkVQAAVWEnwNdu21PgrD77J3xA==",
"bundled": true,
"dev": true,
"optional": true
},
"string_decoder": {
"version": "1.1.1",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20120,7 +20077,6 @@
},
"string-width": {
"version": "1.0.2",
- "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20132,7 +20088,6 @@
},
"strip-ansi": {
"version": "3.0.1",
- "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20142,14 +20097,12 @@
},
"strip-json-comments": {
"version": "2.0.1",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"bundled": true,
"dev": true,
"optional": true
},
"tar": {
"version": "4.4.1",
- "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20165,14 +20118,12 @@
},
"util-deprecate": {
"version": "1.0.2",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"bundled": true,
"dev": true,
"optional": true
},
"wide-align": {
"version": "1.1.2",
- "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
"bundled": true,
"dev": true,
"optional": true,
@@ -20182,14 +20133,12 @@
},
"wrappy": {
"version": "1.0.2",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"bundled": true,
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.2",
- "integrity": "sha512-U+iKQ8rDYMRmvEpvDUIWZ3CtM9/imlAc+c1yJ7YV0vu+HNtP82sAkXzuDXPLkIPoLZohnXFSs9wf2E17xk5yZA==",
"bundled": true,
"dev": true,
"optional": true
diff --git a/src/loaders/mixpanel-jslib-snippet.js b/src/loaders/mixpanel-jslib-snippet.js
index 93edb980..d74cf199 100644
--- a/src/loaders/mixpanel-jslib-snippet.js
+++ b/src/loaders/mixpanel-jslib-snippet.js
@@ -53,7 +53,7 @@ var MIXPANEL_LIB_URL = '//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js';
// create shallow clone of the public mixpanel interface
// Note: only supports 1 additional level atm, e.g. mixpanel.people.set, not mixpanel.people.set.do_something_else.
- functions = "disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders start_session_recording stop_session_recording people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(' ');
+ functions = "disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders start_session_recording stop_session_recording heartbeat people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(' ');
for (i = 0; i < functions.length; i++) {
_set_and_defer(target, functions[i]);
}
@@ -79,6 +79,17 @@ var MIXPANEL_LIB_URL = '//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js';
return mock_group;
};
+ // special case for heartbeat(): simple stub with start/stop methods
+ target['heartbeat'] = function() {
+ target.push(['heartbeat'].concat(Array.prototype.slice.call(arguments, 0)));
+ };
+ target['heartbeat']['start'] = function() {
+ target.push(['heartbeat.start'].concat(Array.prototype.slice.call(arguments, 0)));
+ };
+ target['heartbeat']['stop'] = function() {
+ target.push(['heartbeat.stop'].concat(Array.prototype.slice.call(arguments, 0)));
+ };
+
// register mixpanel instance
mixpanel['_i'].push([token, config, name]);
};
diff --git a/src/mixpanel-core.js b/src/mixpanel-core.js
index f930711b..4b31156f 100644
--- a/src/mixpanel-core.js
+++ b/src/mixpanel-core.js
@@ -86,6 +86,13 @@ if (navigator['sendBeacon']) {
};
}
+// Heartbeat configuration constants
+var DEFAULT_HEARTBEAT_TIMEOUT = 30000; // 30 seconds
+var DEFAULT_HEARTBEAT_INTERVAL = 5000; // 5 seconds
+var MIN_HEARTBEAT_INTERVAL = 100; // 100ms
+var MAX_HEARTBEAT_INTERVAL = 300000; // 5 minutes
+var MAX_HEARTBEAT_STORAGE_SIZE = 500;
+
var DEFAULT_API_ROUTES = {
'track': 'track/',
'engage': 'engage/',
@@ -329,11 +336,9 @@ MixpanelLib.prototype._init = function(token, config, name) {
// the events will be flushed again on startup and deduplicated on the Mixpanel server
// side.
// There is no reliable way to capture only page close events, so we lean on the
- // visibilitychange and pagehide events as recommended at
+ // beforeunload and pagehide events as recommended at
// https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event#usage_notes.
- // These events fire when the user clicks away from the current page/tab, so will occur
- // more frequently than page unload, but are the only mechanism currently for capturing
- // this scenario somewhat reliably.
+ // These events fire when the user navigates away from or closes the page.
var flush_on_unload = _.bind(function() {
if (!this.request_batchers.events.stopped) {
this.request_batchers.events.flush({unloading: true});
@@ -361,7 +366,7 @@ MixpanelLib.prototype._init = function(token, config, name) {
if (!this.get_distinct_id()) {
// There is no need to set the distinct id
// or the device id if something was already stored
- // in the persitence
+ // in the persistence
this.register_once({
'distinct_id': DEVICE_ID_PREFIX + uuid,
'$device_id': uuid
@@ -385,6 +390,7 @@ MixpanelLib.prototype._init = function(token, config, name) {
this._init_tab_id();
this._check_and_start_session_recording();
+ this._init_heartbeat();
};
/**
@@ -1121,6 +1127,504 @@ MixpanelLib.prototype.track = addOptOutCheckMixpanelLib(function(event_name, pro
return ret;
});
+/**
+ * Initializes the heartbeat tracking system for the instance
+ * @private
+ */
+MixpanelLib.prototype._init_heartbeat = function() {
+ var self = this;
+
+ this._heartbeat_timers = {};
+ this._heartbeat_storage = {};
+ this._heartbeat_unload_setup = false;
+ this._heartbeat_counters = {};
+ this._heartbeat_intervals = {};
+ this._heartbeat_manual_events = {};
+ this._heartbeat_managed_events = {};
+
+ this._setup_heartbeat_unload_handlers();
+
+
+ /**
+ * Client-side aggregation for streaming analytics events like video watch time,
+ * podcast listen time, or other continuous interactions. Designed to be called
+ * in loops without exploding row counts.
+ *
+ * Heartbeat works by aggregating properties client-side until the event is flushed.
+ * Properties are merged intelligently:
+ * - Numbers are added together
+ * - Strings take the latest value
+ * - Objects are merged (latest overwrites)
+ * - Arrays have elements appended
+ *
+ * Events auto-flush after 30 seconds (configurable) or on page unload.
+ *
+ * Each event automatically tracks:
+ * - $duration: Seconds from first to last heartbeat call
+ * - $heartbeats: Number of heartbeat calls made
+ * - $contentId: The contentId parameter
+ *
+ * @function heartbeat
+ * @memberof mixpanel
+ * @param {String} eventName The name of the event to track
+ * @param {String} contentId Unique identifier for the content being tracked
+ * @param {Object} [props] Properties to aggregate with existing data
+ * @param {Object} [options] Configuration options
+ * @param {Number} [options.timeout] Timeout in milliseconds (default 30000)
+ * @param {Boolean} [options.forceFlush] Force immediate flush after aggregation
+ * @returns {Void}
+ *
+ * @example
+ * // Basic video tracking
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' });
+ * mixpanel.heartbeat('video_watch', 'video_123', { position: 30 });
+ * // After 30 seconds: { quality: 'HD', position: 30, $duration: 30, $heartbeats: 2 }
+ *
+ * @example
+ * // Force immediate flush
+ * mixpanel.heartbeat('podcast_listen', 'episode_123', { platform: 'mobile' }, { forceFlush: true });
+ *
+ * @example
+ * // Custom timeout (60 seconds)
+ * mixpanel.heartbeat('video_watch', 'video_123', { quality: 'HD' }, { timeout: 60000 });
+ */
+ this.heartbeat = function(eventName, contentId, props, options) {
+ return self._heartbeat_impl(eventName, contentId, props, options);
+ };
+
+ this.heartbeat.start = function(eventName, contentId, props, options) {
+ return self._heartbeat_start_impl(eventName, contentId, props, options);
+ };
+
+ this.heartbeat.stop = function(eventName, contentId, options) {
+ return self._heartbeat_stop_impl(eventName, contentId, options);
+ };
+
+};
+
+/**
+ * Sets up page unload handlers for heartbeat auto-flush
+ * @private
+ */
+MixpanelLib.prototype._setup_heartbeat_unload_handlers = function() {
+ if (this._heartbeat_unload_setup) {
+ return;
+ }
+ this._heartbeat_unload_setup = true;
+
+ var self = this;
+ var flush_on_unload = function() {
+ self._heartbeat_log('Page unload detected, flushing heartbeat events');
+ self._heartbeat_flush_all('pageUnload', true);
+ };
+
+ if (typeof window !== 'undefined' && window.addEventListener) {
+ // beforeunload: best-effort, doesn't work on mobile but covers desktop page refreshes
+ window.addEventListener('beforeunload', flush_on_unload);
+ window.addEventListener('pagehide', function(ev) {
+ if (ev['persisted']) {
+ flush_on_unload();
+ }
+ });
+ // Note: visibilitychange removed - users can still consume content when tab loses focus
+ // (e.g., listening to audio, background video). Only flush on actual page navigation.
+ // window.addEventListener('visibilitychange', function() {
+ // if (document['visibilityState'] === 'hidden') {
+ // flush_on_unload();
+ // }
+ // });
+ }
+};
+
+/**
+ * Gets heartbeat event storage from memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_get_storage = function() {
+ return this._heartbeat_storage || {};
+};
+
+/**
+ * Saves heartbeat events to memory
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_save_storage = function(data) {
+ this._heartbeat_storage = data;
+};
+
+
+/**
+ * Helper to format eventName and contentId consistently for logging
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_format_event = function(eventName, contentId) {
+ return eventName + ' | ' + contentId;
+};
+
+/**
+ * Helper to format eventKey consistently for logging
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_format_event_key = function(eventKey) {
+ return eventKey.replace('|', ' | ');
+};
+
+/**
+ * Logs heartbeat debug messages if logging is enabled
+ * Logs when either global debug is true
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_log = function() {
+ var globalDebugEnabled = this.get_config('debug');
+ if (globalDebugEnabled) {
+ var args = Array.prototype.slice.call(arguments);
+ args[0] = '[mixpanel-heartbeat] ' + args[0];
+ try {
+ if (typeof window !== 'undefined' && window.console && window.console.log) {
+ window.console.log.apply(window.console, args);
+ }
+ } catch (err) {
+ _.each(args, function(arg) {
+ if (typeof window !== 'undefined' && window.console && window.console.log) {
+ window.console.log(arg);
+ }
+ });
+ }
+ }
+};
+
+/**
+ * Aggregates properties according to heartbeat rules
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_aggregate_props = function(existingProps, newProps) {
+ var result = _.extend({}, existingProps);
+
+ _.each(newProps, function(newValue, key) {
+ if (!(key in result)) {
+ result[key] = newValue;
+ } else {
+ var existingValue = result[key];
+ var newType = typeof newValue;
+ var existingType = typeof existingValue;
+
+ if (newType === 'number' && existingType === 'number') {
+ result[key] = newValue;
+ } else if (newType === 'string') {
+ result[key] = newValue;
+ } else if (newType === 'object' && existingType === 'object') {
+ if (_.isArray(newValue) && _.isArray(existingValue)) {
+ var combined = existingValue.concat(newValue);
+ if (combined.length > 50) {
+ result[key] = combined.slice(-50);
+ } else {
+ result[key] = combined;
+ }
+ } else if (!_.isArray(newValue) && !_.isArray(existingValue)) {
+ result[key] = _.extend({}, existingValue, newValue);
+ } else {
+ result[key] = newValue;
+ }
+ } else {
+ result[key] = newValue;
+ }
+ }
+ });
+
+ return result;
+};
+
+
+/**
+ * Clears the auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_clear_timer = function(eventKey) {
+ if (eventKey in this._heartbeat_timers) {
+ clearTimeout(this._heartbeat_timers[eventKey]);
+ delete this._heartbeat_timers[eventKey];
+ }
+};
+
+/**
+ * Sets up auto-flush timer for a specific event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_setup_timer = function(eventKey, timeout) {
+ var self = this;
+ try {
+ var hadExistingTimer = (eventKey in this._heartbeat_timers);
+ self._heartbeat_clear_timer(eventKey);
+ if (hadExistingTimer) {
+ // this log is super noisy... so leaving out for now
+ // this._heartbeat_log('Timer restarted for', eventKey);
+ }
+
+ var timerId = setTimeout(function() {
+ try {
+ self._heartbeat_log('Timer expired, flushing', self._heartbeat_format_event_key(eventKey));
+ self._heartbeat_flush_event(eventKey, 'timeout', false);
+ } catch (e) {
+ self.report_error('Error in heartbeat timeout handler: ' + e.message);
+ }
+ }, timeout || 30000);
+
+ this._heartbeat_timers[eventKey] = timerId;
+ } catch (e) {
+ self.report_error('Error setting up heartbeat timer: ' + e.message);
+ }
+};
+
+/**
+ * Flushes a single heartbeat event
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_event = function(eventKey, reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var eventData = storage[eventKey];
+
+ if (!eventData) {
+ return;
+ }
+
+ var eventName = eventData.eventName;
+ var props = eventData.props;
+
+ this._heartbeat_clear_timer(eventKey);
+
+ var trackingProps = _.extend({}, props);
+
+ var transportOptions = useSendBeacon ? { transport: 'sendBeacon' } : {};
+
+ try {
+ this.track(eventName, trackingProps, transportOptions);
+ this._heartbeat_log('Flushed event', this._heartbeat_format_event_key(eventKey), 'reason:', reason, 'props:', trackingProps);
+ } catch (error) {
+ this.report_error('Error flushing heartbeat event: ' + error.message);
+ }
+
+ delete storage[eventKey];
+ this._heartbeat_save_storage(storage);
+
+ delete this._heartbeat_manual_events[eventKey];
+ delete this._heartbeat_managed_events[eventKey];
+ delete this._heartbeat_counters[eventKey];
+
+};
+
+/**
+ * Flushes all heartbeat events
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_flush_all = function(reason, useSendBeacon) {
+ var storage = this._heartbeat_get_storage();
+ var keys = Object.keys(storage);
+
+ this._heartbeat_log('Flushing all heartbeat events, count:', keys.length, 'reason:', reason);
+
+ for (var i = 0; i < keys.length; i++) {
+ this._heartbeat_flush_event(keys[i], reason, useSendBeacon);
+ }
+};
+
+/**
+ * Internal heartbeat logic (used by both manual and managed APIs)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_internal = function(eventName, contentId, props, options) {
+ var eventKey = eventName + '|' + contentId;
+ this._heartbeat_counters[eventKey] = (this._heartbeat_counters[eventKey] || 0) + 1;
+ this._heartbeat_log('beat #' + this._heartbeat_counters[eventKey], this._heartbeat_format_event(eventName, contentId));
+
+ var storage = this._heartbeat_get_storage();
+
+ var storageKeys = Object.keys(storage);
+ if (storageKeys.length >= MAX_HEARTBEAT_STORAGE_SIZE && !(eventKey in storage)) {
+ this.report_error('heartbeat: Maximum storage size reached, flushing oldest event');
+ var oldestKey = storageKeys[0];
+ this._heartbeat_flush_event(oldestKey, 'maxStorageSize', false);
+ storage = this._heartbeat_get_storage();
+ }
+
+ var currentTime = new Date().getTime();
+
+ if (storage[eventKey]) {
+ var existingData = storage[eventKey];
+ var aggregatedProps = this._heartbeat_aggregate_props(existingData.props, props);
+
+ var durationSeconds = Math.round((currentTime - existingData.firstCall)) / 1000;
+ aggregatedProps['$duration'] = Math.round(durationSeconds * 1000) / 1000;
+ aggregatedProps['$heartbeats'] = (existingData.hitCount || 1) + 1;
+ aggregatedProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ props: aggregatedProps,
+ lastUpdate: currentTime,
+ firstCall: existingData.firstCall,
+ hitCount: (existingData.hitCount || 1) + 1
+ };
+
+ } else {
+ var newProps = _.extend({}, props);
+ newProps['$duration'] = 0;
+ newProps['$heartbeats'] = 1;
+ newProps['$contentId'] = contentId;
+
+ storage[eventKey] = {
+ eventName: eventName,
+ props: newProps,
+ lastUpdate: currentTime,
+ firstCall: currentTime,
+ hitCount: 1
+ };
+
+ }
+
+ this._heartbeat_save_storage(storage);
+
+ if (options.forceFlush) {
+ this._heartbeat_log('Force flushing requested');
+ this._heartbeat_flush_event(eventKey, 'forceFlush', false);
+ } else if (!options._managed) {
+ var timeout = options.timeout || DEFAULT_HEARTBEAT_TIMEOUT;
+ this._heartbeat_setup_timer(eventKey, timeout);
+ }
+
+ return;
+};
+
+/**
+ * Main heartbeat implementation (public API)
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat: eventName and contentId are required');
+ return;
+ }
+
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ if (eventKey in this._heartbeat_managed_events) {
+ this.report_error('heartbeat: Cannot call heartbeat() on an event managed by heartbeat.start(). Use heartbeat.stop() first.');
+ return;
+ }
+
+ this._heartbeat_manual_events[eventKey] = true;
+
+ this._heartbeat_internal(eventName, contentId, props, options);
+
+ return;
+});
+
+/**
+ * Start implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_start_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, props, options) {
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.start: eventName and contentId are required');
+ return;
+ }
+
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ props = props || {};
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ if (eventKey in this._heartbeat_manual_events) {
+ this.report_error('heartbeat.start: Cannot start managed heartbeat on an event already using manual heartbeat() calls. Stop calling heartbeat() first.');
+ return;
+ }
+
+ if (eventKey in this._heartbeat_managed_events) {
+ this.report_error('heartbeat.start: Event already started, restarting with new parameters');
+ this._heartbeat_stop_impl(eventName, contentId, { forceFlush: true });
+ }
+
+ var storage = this._heartbeat_get_storage();
+ var isResuming = eventKey in storage && !(eventKey in this._heartbeat_managed_events);
+ if (isResuming) {
+ this._heartbeat_log('Resuming paused session for', this._heartbeat_format_event_key(eventKey));
+ this._heartbeat_clear_timer(eventKey);
+ }
+
+ this._heartbeat_managed_events[eventKey] = true;
+
+ var interval = options.interval || DEFAULT_HEARTBEAT_INTERVAL;
+
+ if (typeof interval !== 'number' || interval < MIN_HEARTBEAT_INTERVAL) {
+ this.report_error('heartbeat.start: interval must be a number >= ' + MIN_HEARTBEAT_INTERVAL + 'ms, using default ' + DEFAULT_HEARTBEAT_INTERVAL + 'ms');
+ interval = DEFAULT_HEARTBEAT_INTERVAL;
+ }
+ if (interval > MAX_HEARTBEAT_INTERVAL) {
+ this.report_error('heartbeat.start: interval too large, using maximum ' + MAX_HEARTBEAT_INTERVAL + 'ms');
+ interval = MAX_HEARTBEAT_INTERVAL;
+ }
+
+ var self = this;
+
+ this._heartbeat_log('start() for', this._heartbeat_format_event_key(eventKey), 'interval:', interval + 'ms');
+
+ var intervalId = setInterval(function() {
+ self._heartbeat_internal(eventName, contentId, props, { timeout: DEFAULT_HEARTBEAT_TIMEOUT, _managed: true });
+ }, interval);
+
+ this._heartbeat_intervals[eventKey] = intervalId;
+
+ return;
+});
+
+/**
+ * Stop implementation for managed heartbeat intervals
+ * @private
+ */
+MixpanelLib.prototype._heartbeat_stop_impl = addOptOutCheckMixpanelLib(function(eventName, contentId, options) {
+ if (!eventName || !contentId) {
+ this.report_error('heartbeat.stop: eventName and contentId are required');
+ return;
+ }
+
+ eventName = eventName.toString();
+ contentId = contentId.toString();
+ options = options || {};
+
+ var eventKey = eventName + '|' + contentId;
+
+ this._heartbeat_log('stop() for', this._heartbeat_format_event_key(eventKey));
+
+ if (eventKey in this._heartbeat_intervals) {
+ clearInterval(this._heartbeat_intervals[eventKey]);
+ delete this._heartbeat_intervals[eventKey];
+ }
+
+ delete this._heartbeat_managed_events[eventKey];
+
+ // NEW BEHAVIOR: Only flush immediately if forceFlush is true
+ if (options.forceFlush) {
+ this._heartbeat_flush_event(eventKey, 'stopForceFlush', false);
+ } else {
+ // Just pause the session - data remains for potential restart or auto-flush
+ this._heartbeat_log('paused for', this._heartbeat_format_event_key(eventKey), '- awaiting restart or auto-flush');
+ // Set up 30-second inactivity timer if not already present
+ if (!(eventKey in this._heartbeat_timers)) {
+ this._heartbeat_setup_timer(eventKey, 30000);
+ }
+ }
+
+ return;
+});
+
+
/**
* Register the current user into one/many groups.
*
@@ -1855,6 +2359,7 @@ MixpanelLib.prototype.name_tag = function(name_tag) {
*
* // whether to ignore or respect the web browser's Do Not Track setting
* ignore_dnt: false
+ *
* }
*
*
@@ -2303,7 +2808,7 @@ var override_mp_init_func = function() {
// main mixpanel lib already initialized
instance = instances[PRIMARY_INSTANCE_NAME];
} else if (token) {
- // intialize the main mixpanel lib
+ // initialize the main mixpanel lib
instance = create_mplib(token, config, PRIMARY_INSTANCE_NAME);
instance._loaded();
instances[PRIMARY_INSTANCE_NAME] = instance;
diff --git a/tests/test.js b/tests/test.js
index d04007c0..64ec7669 100755
--- a/tests/test.js
+++ b/tests/test.js
@@ -637,6 +637,148 @@
same(data1.properties.time, 123456);
});
+ mpmodule("mixpanel.heartbeat", function() {
+ this.clock = sinon.useFakeTimers();
+ }, function() {
+ this.clock.restore();
+ });
+
+ test("basic heartbeat functionality", 2, function() {
+ // Verify heartbeat method exists and is callable
+ ok(_.isFunction(mixpanel.test.heartbeat), "heartbeat method should exist");
+
+ // Test basic heartbeat call doesn't throw errors
+ var result = mixpanel.test.heartbeat('test_event', 'test_content', { prop: 'value' });
+ same(result, undefined, "heartbeat should return undefined");
+ });
+
+ test("heartbeat return value", 1, function() {
+ var result1 = mixpanel.test.heartbeat('test_event', 'content_1', { prop: 'value' });
+
+ same(result1, undefined, "heartbeat should return undefined (no chaining)");
+ });
+
+ test("heartbeat automatic properties", 2, function() {
+ // Call heartbeat a few times
+ mixpanel.test.heartbeat('duration_test', 'content_1', { custom_prop: 'value1' });
+ this.clock.tick(3000); // 3 seconds
+ mixpanel.test.heartbeat('duration_test', 'content_1', { custom_prop: 'value2' });
+
+ // Force flush and capture the track call
+ var originalTrack = mixpanel.test.track;
+ var trackCalls = [];
+ mixpanel.test.track = function(eventName, props, options) {
+ trackCalls.push({eventName: eventName, props: props, options: options});
+ return originalTrack.call(this, eventName, props, options);
+ };
+
+ // Use forceFlush option to trigger immediate flush
+ mixpanel.test.heartbeat('duration_test', 'content_1', { custom_prop: 'value3' }, { forceFlush: true });
+
+ // Restore original track
+ mixpanel.test.track = originalTrack;
+
+ // Verify the event was tracked with automatic properties
+ same(trackCalls.length, 1, "should have made one track call");
+ if (trackCalls.length > 0) {
+ var trackedProps = trackCalls[0].props;
+ same(trackedProps.$heartbeats, 3, "should track correct number of heartbeats");
+ // Note: Duration might be 3 due to timing, but we mainly want to verify it exists
+ }
+ });
+
+ test("heartbeat argument validation", 1, function() {
+ callsError(function(done) {
+ mixpanel.test.heartbeat('only_event_name');
+ done();
+ }, "eventName and contentId are required", "should report_error about missing contentId");
+ });
+
+ test("heartbeat timeout functionality", 1, function() {
+ var originalTrack = mixpanel.test.track;
+ var trackCalls = [];
+ mixpanel.test.track = function(eventName, props, options) {
+ trackCalls.push({eventName: eventName, props: props, options: options});
+ return originalTrack.call(this, eventName, props, options);
+ };
+
+ // Call heartbeat with custom timeout
+ mixpanel.test.heartbeat('timeout_test', 'content_1', { progress: 25 }, { timeout: 5000 });
+
+ // Advance time by 5 seconds
+ this.clock.tick(5000);
+
+ // Restore original track
+ mixpanel.test.track = originalTrack;
+
+ same(trackCalls.length, 1, "custom timeout should have triggered automatic flush");
+ });
+
+ test("heartbeat property aggregation", 4, function() {
+ var originalTrack = mixpanel.test.track;
+ var trackCalls = [];
+ mixpanel.test.track = function(eventName, props, options) {
+ trackCalls.push({eventName: eventName, props: props, options: options});
+ return originalTrack.call(this, eventName, props, options);
+ };
+
+ // Test different property types get aggregated correctly
+ mixpanel.test.heartbeat('aggregate_test', 'content_1', {
+ score: 10,
+ level: 'easy',
+ tags: ['action'],
+ metadata: { version: 1 }
+ });
+
+ mixpanel.test.heartbeat('aggregate_test', 'content_1', {
+ score: 25,
+ level: 'medium',
+ tags: ['puzzle'],
+ metadata: { difficulty: 'hard' }
+ });
+
+ // Force flush to capture aggregated result
+ mixpanel.test.heartbeat('aggregate_test', 'content_1', {}, { forceFlush: true });
+
+ // Restore original track
+ mixpanel.test.track = originalTrack;
+
+ // Verify aggregation
+ same(trackCalls.length, 1, "should have made one track call");
+ if (trackCalls.length > 0) {
+ var props = trackCalls[0].props;
+ same(props.score, 25, "numbers should use latest value");
+ same(props.level, 'medium', "strings should use latest value");
+ deepEqual(props.tags, ['action', 'puzzle'], "arrays should be concatenated");
+ }
+ });
+
+ test("heartbeat different timeouts", 1, function() {
+ var originalTrack = mixpanel.test.track;
+ var trackCalls = [];
+ mixpanel.test.track = function(eventName, props, options) {
+ trackCalls.push({eventName: eventName, props: props, options: options});
+ return originalTrack.call(this, eventName, props, options);
+ };
+
+ // Start with long timeout
+ mixpanel.test.heartbeat('timeout_override', 'content_1', { step: 1 }, { timeout: 10000 });
+
+ // Advance halfway
+ this.clock.tick(5000);
+
+ // Override with short timeout (should reset timer)
+ mixpanel.test.heartbeat('timeout_override', 'content_1', { step: 2 }, { timeout: 2000 });
+
+ // Advance 2 seconds (should flush now, not wait for original 10s)
+ this.clock.tick(2000);
+
+ // Restore original track
+ mixpanel.test.track = originalTrack;
+
+ same(trackCalls.length, 1, "latest timeout should override previous timeout");
+ });
+
mpmodule("mixpanel.time_event", function() {
this.clock = sinon.useFakeTimers();
}, function() {
diff --git a/tests/unit/heartbeat.js b/tests/unit/heartbeat.js
new file mode 100644
index 00000000..3cb91a7a
--- /dev/null
+++ b/tests/unit/heartbeat.js
@@ -0,0 +1,634 @@
+import chai, { expect } from 'chai';
+import sinon from 'sinon';
+import sinonChai from 'sinon-chai';
+
+chai.use(sinonChai);
+
+import { _, console } from '../../src/utils';
+import { clearOptInOut } from '../../src/gdpr-utils';
+import mixpanel from '../../src/loaders/loader-module';
+
+// This is a test specifically for the heartbeat method behavior.
+// We test by exercising the real implementation through a minimal mock.
+
+describe('Heartbeat', function() {
+ let clock, originalTrack, originalReportError;
+
+ beforeEach(function() {
+ clock = sinon.useFakeTimers();
+ clearOptInOut();
+
+ // Create a named instance for heartbeat testing to avoid polluting main scope
+ mixpanel.init('test-token', {
+ api_host: 'localhost',
+ debug: false,
+ persistence: 'localStorage'
+ }, 'hb');
+
+ // Clean up any existing heartbeat state on the test instance
+ if (mixpanel.hb._heartbeat_intervals) {
+ // Clean up intervals using object keys since we're using plain objects now
+ Object.keys(mixpanel.hb._heartbeat_intervals).forEach((key) => {
+ clearInterval(mixpanel.hb._heartbeat_intervals[key]);
+ });
+ mixpanel.hb._heartbeat_intervals = {};
+ }
+ if (mixpanel.hb._heartbeat_timers) {
+ // Clean up timers using object keys since we're using plain objects now
+ Object.keys(mixpanel.hb._heartbeat_timers).forEach((key) => {
+ clearTimeout(mixpanel.hb._heartbeat_timers[key]);
+ });
+ mixpanel.hb._heartbeat_timers = {};
+ }
+ if (mixpanel.hb._heartbeat_storage) {
+ mixpanel.hb._heartbeat_storage = {};
+ }
+ if (mixpanel.hb._heartbeat_manual_events) {
+ mixpanel.hb._heartbeat_manual_events = {};
+ }
+ if (mixpanel.hb._heartbeat_managed_events) {
+ mixpanel.hb._heartbeat_managed_events = {};
+ }
+ if (mixpanel.hb._heartbeat_counters) {
+ mixpanel.hb._heartbeat_counters = {};
+ }
+
+ // Store original methods and stub only the external dependencies on the test instance
+ originalTrack = mixpanel.hb.track;
+ originalReportError = mixpanel.hb.report_error;
+
+ mixpanel.hb.track = sinon.stub();
+ mixpanel.hb.report_error = sinon.stub();
+ });
+
+ afterEach(function() {
+ clock.restore();
+ clearOptInOut();
+
+ // Restore original methods on the test instance
+ mixpanel.hb.track = originalTrack;
+ mixpanel.hb.report_error = originalReportError;
+
+ // Clean up the named instance
+ delete mixpanel.hb;
+ });
+
+ describe('Basic functionality', function() {
+ it('should exist as a function', function() {
+ expect(mixpanel.hb.heartbeat).to.be.a('function');
+ });
+
+ it('should require eventName and contentId', function() {
+ mixpanel.hb.heartbeat();
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat('event');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required');
+ });
+
+ it('should convert parameters to strings', function() {
+ mixpanel.hb.heartbeat(123, 456, { prop: 'value' });
+
+ // Verify the conversion by forcing a flush and checking track call
+ mixpanel.hb.heartbeat(123, 456, {}, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.called;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('123'); // eventName converted to string
+ expect(trackCall.args[1]).to.include({ $contentId: '456' }); // contentId converted to string
+ });
+
+ it('should handle invalid parameters', function() {
+ mixpanel.hb.heartbeat('', 'content');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat('event', '');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat(null, 'content');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required');
+ });
+
+ it('should track events with automatic properties', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { custom: 'prop' }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+
+ expect(trackCall.args[0]).to.equal('test_event');
+ expect(trackCall.args[1]).to.include({
+ custom: 'prop',
+ $contentId: 'content_123',
+ $heartbeats: 1,
+ $duration: 0
+ });
+ });
+
+ it('should auto-flush after timeout', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' });
+
+ // Should not have tracked yet
+ expect(mixpanel.hb.track).not.to.have.been.called;
+
+ // Advance time by 30 seconds (default timeout)
+ clock.tick(30000);
+
+ // Should have auto-flushed
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('test_event');
+ expect(trackCall.args[1]).to.include({
+ prop: 'value',
+ $contentId: 'content_123'
+ });
+ });
+
+ it('should respect custom timeout', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' }, { timeout: 60000 });
+
+ // Should not flush after 30 seconds
+ clock.tick(30000);
+ expect(mixpanel.hb.track).not.to.have.been.called;
+
+ // Should flush after 60 seconds
+ clock.tick(30000);
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ });
+
+ it('should force flush immediately when requested', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ });
+
+ it('should return undefined (no chaining)', function() {
+ const result = mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' });
+ expect(result).to.be.undefined;
+ });
+ });
+
+ describe('Property aggregation behavior', function() {
+ it('should aggregate numbers by using latest value', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { currentTime: 10 });
+ mixpanel.hb.heartbeat('test_event', 'content_123', { currentTime: 25 }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1]).to.include({ currentTime: 25 });
+ });
+
+ it('should aggregate strings by using latest value', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { status: 'playing' });
+ mixpanel.hb.heartbeat('test_event', 'content_123', { status: 'paused' }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1]).to.include({ status: 'paused' });
+ });
+
+ it('should aggregate arrays by concatenating', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { events: ['start'] });
+ mixpanel.hb.heartbeat('test_event', 'content_123', { events: ['pause'] }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1].events).to.deep.equal(['start', 'pause']);
+ });
+
+ it('should aggregate objects by merging', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { metadata: { quality: 'HD' } });
+ mixpanel.hb.heartbeat('test_event', 'content_123', { metadata: { volume: 0.8 } }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1].metadata).to.deep.equal({
+ quality: 'HD',
+ volume: 0.8
+ });
+ });
+
+ it('should update heartbeat count and duration', function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'first' });
+
+ clock.tick(2000); // Advance 2 seconds
+
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'second' }, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1]).to.include({
+ $heartbeats: 2,
+ $duration: 2 // 2 seconds
+ });
+ });
+
+ it('should handle concurrent heartbeats with same eventName but different contentId', function() {
+ // Start heartbeats for two different content items with same event name
+ mixpanel.hb.heartbeat('video_watch', 'video_123', { score: 100, platform: 'html5' });
+ mixpanel.hb.heartbeat('video_watch', 'video_456', { score: 200, platform: 'youtube' });
+
+ clock.tick(1000); // Advance 1 second
+
+ // Add more data to each and force flush on the second call
+ mixpanel.hb.heartbeat('video_watch', 'video_123', { score: 50, quality: 'HD' }, { forceFlush: true });
+ mixpanel.hb.heartbeat('video_watch', 'video_456', { score: 75, quality: '4K' }, { forceFlush: true });
+
+ // Should have called track twice (once for each contentId)
+ expect(mixpanel.hb.track).to.have.been.calledTwice;
+
+ // Check first event (video_123)
+ const firstCall = mixpanel.hb.track.getCall(0);
+ expect(firstCall.args[0]).to.equal('video_watch');
+ expect(firstCall.args[1]).to.include({
+ $contentId: 'video_123',
+ score: 50, // Latest value (not 100 + 50)
+ platform: 'html5',
+ quality: 'HD', // Latest value
+ $heartbeats: 2,
+ $duration: 1
+ });
+
+ // Check second event (video_456)
+ const secondCall = mixpanel.hb.track.getCall(1);
+ expect(secondCall.args[0]).to.equal('video_watch');
+ expect(secondCall.args[1]).to.include({
+ $contentId: 'video_456',
+ score: 75, // Latest value (not 200 + 75)
+ platform: 'youtube',
+ quality: '4K', // Latest value
+ $heartbeats: 2,
+ $duration: 1
+ });
+ });
+ });
+
+ describe('Storage management', function() {
+ it('should handle storage size limit', function() {
+ // This test verifies the behavior when hitting the storage limit
+ // We'll create many unique events to trigger the limit
+ for (let i = 0; i < 501; i++) {
+ mixpanel.hb.heartbeat('event', `content_${i}`, { prop: i });
+ }
+
+ // Should have auto-flushed at least one event due to storage limit
+ expect(mixpanel.hb.track).to.have.been.called;
+ });
+ });
+
+ describe('Debug configuration', function() {
+ it('should handle debug mode configuration changes', function() {
+ // Should not throw errors when debug mode is enabled or disabled
+ expect(function() {
+ mixpanel.hb.set_config({ debug: true });
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' });
+
+ mixpanel.hb.set_config({ debug: false });
+ mixpanel.hb.heartbeat('test_event', 'content_456', { prop: 'value' });
+ }).not.to.throw();
+ });
+ });
+
+ describe('Error handling', function() {
+ it('should handle track method failures gracefully', function() {
+ mixpanel.hb.track.throws(new Error('Network failure'));
+
+ // Should not throw error when flushing
+ expect(function() {
+ mixpanel.hb.heartbeat('test_event', 'content_123', { prop: 'value' }, { forceFlush: true });
+ }).not.to.throw();
+
+ // Should report the error
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('Error flushing heartbeat event: Network failure');
+ });
+ });
+
+ describe('API compatibility', function() {
+ it('should not expose old sub-methods', function() {
+ // Verify that old chaining methods don't exist
+ expect(mixpanel.hb.heartbeat.flush).to.be.undefined;
+ expect(mixpanel.hb.heartbeat.clear).to.be.undefined;
+ expect(mixpanel.hb.heartbeat.getState).to.be.undefined;
+ expect(mixpanel.hb.heartbeat.getConfig).to.be.undefined;
+ expect(mixpanel.hb.heartbeat.flushByContentId).to.be.undefined;
+ });
+
+ it('should expose new start/stop methods', function() {
+ // Verify new methods exist
+ expect(mixpanel.hb.heartbeat.start).to.be.a('function');
+ expect(mixpanel.hb.heartbeat.stop).to.be.a('function');
+ });
+ });
+
+ describe('Start/Stop API', function() {
+ describe('heartbeat.start()', function() {
+ it('should exist as a function', function() {
+ expect(mixpanel.hb.heartbeat.start).to.be.a('function');
+ });
+
+ it('should require eventName and contentId', function() {
+ mixpanel.hb.heartbeat.start();
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: eventName and contentId are required');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat.start('event');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: eventName and contentId are required');
+ });
+
+ it('should convert parameters to strings', function() {
+ mixpanel.hb.heartbeat.start(123, 456, { prop: 'value' });
+
+ // Advance time to trigger interval
+ clock.tick(5000);
+
+ // Stop to flush and verify
+ mixpanel.hb.heartbeat.stop(123, 456, { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.called;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('123');
+ expect(trackCall.args[1]).to.include({ $contentId: '456' });
+ });
+
+ it('should start managed heartbeat with default 5-second interval', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+
+ // Should not have tracked immediately
+ expect(mixpanel.hb.track).not.to.have.been.called;
+
+ // Advance time by 5 seconds - should trigger a heartbeat internally
+ clock.tick(5000);
+
+ // Force stop to flush and verify heartbeat was called
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123', { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.called;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('video_watch');
+ expect(trackCall.args[1]).to.include({
+ quality: 'HD',
+ $heartbeats: 1 // Should have been called once by the interval
+ });
+ });
+
+ it('should support custom interval', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' }, { interval: 3000 });
+
+ // Should not track after 2 seconds (no heartbeat interval fired yet)
+ clock.tick(2000);
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123', { forceFlush: true });
+ mixpanel.hb.track.resetHistory();
+
+ // Start again and wait for 3 seconds
+ mixpanel.hb.heartbeat.start('video_watch', 'video_456', { quality: 'HD' }, { interval: 3000 });
+ clock.tick(3000);
+
+ // Should have heartbeat data after 3 seconds - force flush to verify
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_456', { forceFlush: true });
+ expect(mixpanel.hb.track).to.have.been.called;
+ });
+
+ it('should warn and restart if already started', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: '4K' }, { interval: 2000 });
+
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: Event already started, restarting with new parameters');
+
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+ });
+
+ it('should prevent manual heartbeat() calls on started events', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat('video_watch', 'video_123', { manual: true });
+
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat: Cannot call heartbeat() on an event managed by heartbeat.start(). Use heartbeat.stop() first.');
+
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+ });
+
+ it('should prevent starting on manual heartbeat events', function() {
+ mixpanel.hb.heartbeat('video_watch', 'video_123', { manual: true });
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123');
+
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: Cannot start managed heartbeat on an event already using manual heartbeat() calls. Stop calling heartbeat() first.');
+ });
+
+ it('should return undefined (no chaining)', function() {
+ const result = mixpanel.hb.heartbeat.start('video_watch', 'video_123');
+ expect(result).to.be.undefined;
+
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+ });
+
+ it('should validate interval parameter bounds', function() {
+ // Test too small interval
+ mixpanel.hb.heartbeat.start('test_event', 'test_content', {}, { interval: 50 });
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: interval must be a number >= 100ms, using default 5000ms');
+
+ mixpanel.hb.report_error.resetHistory();
+
+ // Test too large interval
+ mixpanel.hb.heartbeat.start('test_event2', 'test_content2', {}, { interval: 400000 });
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: interval too large, using maximum 300000ms');
+
+ mixpanel.hb.report_error.resetHistory();
+
+ // Test invalid type
+ mixpanel.hb.heartbeat.start('test_event3', 'test_content3', {}, { interval: 'invalid' });
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.start: interval must be a number >= 100ms, using default 5000ms');
+
+ // Clean up
+ mixpanel.hb.heartbeat.stop('test_event', 'test_content');
+ mixpanel.hb.heartbeat.stop('test_event2', 'test_content2');
+ mixpanel.hb.heartbeat.stop('test_event3', 'test_content3');
+ });
+ });
+
+ describe('heartbeat.stop()', function() {
+ it('should exist as a function', function() {
+ expect(mixpanel.hb.heartbeat.stop).to.be.a('function');
+ });
+
+ it('should require eventName and contentId', function() {
+ mixpanel.hb.heartbeat.stop();
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.stop: eventName and contentId are required');
+
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat.stop('event');
+ expect(mixpanel.hb.report_error).to.have.been.calledWith('heartbeat.stop: eventName and contentId are required');
+ });
+
+ it('should NOT immediately flush when stopped (unless forceFlush)', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+
+ // Advance time to trigger some heartbeats
+ clock.tick(10000); // 2 heartbeats at 5-second intervals
+
+ mixpanel.hb.track.resetHistory();
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+
+ // Should NOT have flushed immediately (new behavior)
+ expect(mixpanel.hb.track).to.not.have.been.called;
+
+ // But should flush after 30-second inactivity timer
+ clock.tick(30000);
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('video_watch');
+ expect(trackCall.args[1]).to.include({
+ quality: 'HD',
+ $contentId: 'video_123',
+ $heartbeats: 2
+ });
+ });
+
+ it('should flush immediately when stopped with forceFlush: true', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+
+ // Advance time to trigger some heartbeats
+ clock.tick(10000); // 2 heartbeats at 5-second intervals
+
+ mixpanel.hb.track.resetHistory();
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123', { forceFlush: true });
+
+ // Should have flushed immediately with forceFlush
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[0]).to.equal('video_watch');
+ expect(trackCall.args[1]).to.include({
+ quality: 'HD',
+ $contentId: 'video_123',
+ $heartbeats: 2
+ });
+ });
+
+ it('should allow resuming a stopped session with start()', function() {
+ // Start initial session
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: 'HD' });
+ clock.tick(10000); // 2 heartbeats
+
+ // Stop without force flush (pauses session)
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+ expect(mixpanel.hb.track).to.not.have.been.called;
+
+ // Resume the session
+ mixpanel.hb.track.resetHistory();
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123', { quality: '4K' }); // Updated props
+ clock.tick(5000); // 1 more heartbeat
+
+ // Force flush to check aggregated data
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123', { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1]).to.include({
+ quality: '4K', // Latest value
+ $contentId: 'video_123',
+ $heartbeats: 3 // 2 from first session + 1 from resumed session
+ });
+ });
+
+ it('should stop the interval', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123');
+
+ // Stop the heartbeat
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+
+ mixpanel.hb.track.resetHistory();
+
+ // Advance time - should not trigger more heartbeats
+ clock.tick(10000);
+ expect(mixpanel.hb.track).not.to.have.been.called;
+ });
+
+ it('should handle stopping non-existent heartbeat gracefully', function() {
+ expect(function() {
+ mixpanel.hb.heartbeat.stop('video_watch', 'nonexistent');
+ }).not.to.throw();
+ });
+
+ it('should return undefined (no chaining)', function() {
+ mixpanel.hb.heartbeat.start('video_watch', 'video_123');
+ const result = mixpanel.hb.heartbeat.stop('video_watch', 'video_123');
+ expect(result).to.be.undefined;
+ });
+ });
+
+ describe('Integration scenarios', function() {
+ it('should handle multiple concurrent started heartbeats', function() {
+ // Reset track history to ensure clean state
+ mixpanel.hb.track.resetHistory();
+
+ // Start multiple heartbeats
+ mixpanel.hb.heartbeat.start('video_watch', 'video_1', { video: 1 });
+ mixpanel.hb.heartbeat.start('podcast_listen', 'episode_1', { podcast: 1 });
+ mixpanel.hb.heartbeat.start('video_watch', 'video_2', { video: 2 });
+
+ // Advance time to trigger heartbeats
+ clock.tick(5000);
+
+ // Stop all with force flush to ensure immediate tracking
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_1', { forceFlush: true });
+ mixpanel.hb.heartbeat.stop('podcast_listen', 'episode_1', { forceFlush: true });
+ mixpanel.hb.heartbeat.stop('video_watch', 'video_2', { forceFlush: true });
+
+ // Should have tracked all three
+ expect(mixpanel.hb.track).to.have.callCount(3);
+ });
+
+ it('should aggregate properties correctly in managed mode', function() {
+ mixpanel.hb.heartbeat.start('game_session', 'level_1', { score: 100, level: 'easy' });
+
+ // Advance time and let some heartbeats fire
+ clock.tick(10000); // 2 heartbeats
+
+ // Stop with force flush and check aggregation
+ mixpanel.hb.track.resetHistory();
+ mixpanel.hb.heartbeat.stop('game_session', 'level_1', { forceFlush: true });
+
+ expect(mixpanel.hb.track).to.have.been.calledOnce;
+ const trackCall = mixpanel.hb.track.getCall(0);
+ expect(trackCall.args[1]).to.include({
+ score: 100, // Latest value (same as each heartbeat since they all send 100)
+ level: 'easy', // Latest value
+ $heartbeats: 2,
+ $contentId: 'level_1'
+ });
+ });
+ });
+
+ describe('Storage management with start/stop', function() {
+ it('should respect storage size limit with managed heartbeats', function() {
+ // Start many heartbeats and trigger them to fill storage
+ for (let i = 0; i < 500; i++) {
+ mixpanel.hb.heartbeat.start('event', `content_${i}`, { index: i }, { interval: 1000 });
+ }
+
+ // Advance time to trigger all heartbeats and fill storage
+ clock.tick(1000);
+
+ // Start one more - should trigger storage limit warning
+ mixpanel.hb.report_error.resetHistory();
+ mixpanel.hb.heartbeat.start('event', 'content_limit_test', { test: true }, { interval: 1000 });
+ clock.tick(1000); // Trigger the new heartbeat
+
+ // Should have reported storage limit reached
+ expect(mixpanel.hb.report_error).to.have.been.calledWithMatch('Maximum storage size reached');
+
+ // Clean up ALL to avoid affecting other tests
+ for (let i = 0; i < 500; i++) {
+ mixpanel.hb.heartbeat.stop('event', `content_${i}`);
+ }
+ mixpanel.hb.heartbeat.stop('event', 'content_limit_test');
+ });
+ });
+ });
+});
\ No newline at end of file