Created
June 11, 2025 20:08
-
-
Save ak--47/301f8a60ef3af2fef593e958cc34ef2c to your computer and use it in GitHub Desktop.
Mixpanel Heartbeat API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @fileoverview Mixpanel Heartbeat API Extension | |
* | |
* Augments Mixpanel SDK with heartbeat() functionality for event aggregation. | |
* Useful for tracking continuous activities like video/audio playback, reading progress, etc... | |
* This API aggregates small events into summary events before sending to Mixpanel | |
* | |
* @author AK: [email protected] | |
* @version 1.0.1 | |
*/ | |
// ========================================== | |
// DEAD SIMPLE EXAMPLE USAGE | |
// ========================================== | |
// Initialize Mixpanel with the heartbeat plugin | |
mixpanel.init('your-token-here', { loaded: function (mixpanel) { mixpanel.heartbeat = createHeartbeatAPI(mixpanel); } }); | |
// track video watching with aggregation | |
mixpanel.heartbeat('video_watch', 'video_123', { duration: 30, interactions: ['play'] }); | |
mixpanel.heartbeat('video_watch', 'video_123', { duration: 10, interactions: ['pause'] }); | |
mixpanel.heartbeat('video_watch', 'video_123', { duration: 20, interactions: ['resume'] }); | |
// flush the event (or wait for auto-flush) | |
mixpanel.heartbeat.flush(); // sends a single event to Mixpanel: { event: 'video_watch', properties: { contentId: 'video_123', duration: 60, interactions: ['play', 'pause', 'resume'] } } | |
// ========================================== | |
// AUTOMATIC FLUSHING BEHAVIOR | |
// ========================================== | |
/** | |
* Events are automatically flushed in the following scenarios: | |
* | |
* 1. **Content Switching**: When a new contentId is used for the same eventName, | |
* any existing events for that eventName with different contentIds are flushed. | |
* This handles cases like users switching between videos or articles. | |
* | |
* 2. **Time-based Flushing**: Events are automatically flushed after `maxBufferTime` | |
* milliseconds of inactivity (default: 5 minutes). Each new heartbeat call resets | |
* the timer for that specific event. | |
* | |
* 3. **Size Limits**: Events are flushed when they exceed: | |
* - `maxPropsCount`: Maximum number of properties (default: 1000) | |
* - `maxAggregatedValue`: Maximum numeric value for any property (default: 100,000) | |
* | |
* 4. **Page Unload**: All events are flushed when the user leaves the page, | |
* using sendBeacon transport for reliability. Handles multiple browser events: | |
* beforeunload, pagehide, and visibilitychange. | |
* | |
* PROPERTY AGGREGATION RULES | |
* | |
* When the same property key appears in multiple heartbeat calls: | |
* | |
* - **Numbers**: Values are added together | |
* Example: {duration: 30} + {duration: 45} = {duration: 75} | |
* | |
* - **Strings**: Latest value replaces the previous value | |
* Example: {status: 'playing'} + {status: 'paused'} = {status: 'paused'} | |
* | |
* - **Objects**: Shallow merge with the new object's properties overwriting existing ones | |
* Example: {metadata: {quality: 'HD', lang: 'en'}} + {metadata: {quality: '4K', fps: 60}} | |
* Result: {metadata: {quality: '4K', lang: 'en', fps: 60}} | |
* | |
* - **Arrays**: New array elements are appended to the existing array | |
* Example: {actions: ['play', 'pause']} + {actions: ['seek', 'volume']} | |
* Result: {actions: ['play', 'pause', 'seek', 'volume']} | |
* | |
* TRANSPORT OPTIONS | |
* | |
* - **xhr** (default): Standard XMLHttpRequest, suitable for most scenarios | |
* - **sendBeacon**: More reliable for page unload scenarios, automatically used | |
* for page unload events and can be manually specified for critical events | |
* | |
* FLUSH REASONS | |
* | |
* The onFlush callback receives a reason parameter indicating why the flush occurred: | |
* - 'manual': Explicitly called via flush() methods | |
* - 'manualFlushCall': Called via heartbeat() with no parameters | |
* - 'forceFlush': Called with forceFlush option | |
* - 'contentSwitch': Different contentId used for same eventName | |
* - 'maxBufferTime': Maximum time limit reached | |
* - 'maxPropsCount': Maximum property count exceeded | |
* - 'maxAggregatedValue': Maximum numeric value exceeded | |
* - 'pageUnload': Page is being unloaded | |
* | |
* ERROR HANDLING | |
* | |
* - Invalid parameters (missing eventName/contentId) log warnings but don't throw | |
* - Mixpanel tracking errors are caught and logged but don't stop execution | |
* - Timer cleanup is handled automatically to prevent memory leaks | |
* | |
* CROSS-BROWSER COMPATIBILITY | |
* | |
* - Uses multiple page unload event listeners for better browser support | |
* - Gracefully handles environments where sendBeacon is not available | |
* - Memory management with automatic timer cleanup | |
* | |
* PERFORMANCE CONSIDERATIONS | |
* | |
* - Events are stored in memory only (no localStorage/sessionStorage) | |
* - Efficient Map-based storage for fast lookups | |
* - Automatic cleanup of timers and event data after flushing | |
* - Debounced flushing prevents excessive API calls | |
* | |
* DEBUGGING AND MONITORING | |
* | |
* Enable logging to see detailed information about heartbeat operations: | |
* | |
* @example | |
* mixpanel.heartbeat = createHeartbeatAPI(mixpanel, { | |
* enableLogging: true, | |
* onFlush: (data) => { | |
* // Custom monitoring logic | |
* if (data.reason === 'maxAggregatedValue') { | |
* console.warn('Event reached maximum value:', data); | |
* } | |
* } | |
* }); | |
* | |
* MIGRATION FROM DIRECT TRACKING | |
* | |
* Before (sending many individual events): | |
* @example | |
* // Multiple API calls - not efficient | |
* mixpanel.track('video_progress', { video_id: '123', seconds: 10 }); | |
* mixpanel.track('video_progress', { video_id: '123', seconds: 20 }); | |
* mixpanel.track('video_progress', { video_id: '123', seconds: 30 }); | |
* | |
* After (using heartbeat aggregation): | |
* @example | |
* // Single aggregated event sent to Mixpanel | |
* mixpanel.heartbeat('video_watch', '123', { duration: 10, interactions: ['play'] }); | |
* mixpanel.heartbeat('video_watch', '123', { duration: 10, interactions: ['pause'] }); | |
* mixpanel.heartbeat('video_watch', '123', { duration: 10, interactions: ['resume'] }); | |
* // Results in: { video_id: '123', duration: 30, interactions: ['play', 'pause', 'resume'] } | |
*/ | |
// ========================================== | |
// MIXPANEL HEARTBEAT API | |
// ========================================== | |
/** | |
* Aggregates small events into summary events before sending to Mixpanel. | |
* Provides intelligent flushing, deduplication, and transport options. | |
* | |
* @namespace HeartbeatAPI | |
* | |
* @example | |
* // Basic setup | |
* mixpanel.init('your-token', { | |
* loaded: function(mixpanel) { | |
* mixpanel.heartbeat = createHeartbeatAPI(mixpanel, { | |
* maxBufferTime: 300000, | |
* onFlush: (data) => console.log('Flushed:', data) | |
* }); | |
* } | |
* }); | |
* | |
* @example | |
* // Track video watching | |
* mixpanel.heartbeat('video_watch', 'video_123', { | |
* duration: 30, | |
* interactions: ['play'] | |
* }); | |
* | |
* @example | |
* // Force immediate flush with sendBeacon | |
* mixpanel.heartbeat('critical_event', 'content_456', | |
* { amount: 99.99 }, | |
* { forceFlush: true, transport: 'sendBeacon' } | |
* ); | |
*/ | |
/** | |
* Configuration object for heartbeat API | |
* @typedef {Object} HeartbeatConfig | |
* @property {number} [maxBufferTime=300000] - Maximum time in milliseconds before auto-flush (default: 5 minutes) | |
* @property {number} [maxPropsCount=1000] - Maximum number of properties before auto-flush | |
* @property {number} [maxAggregatedValue=100000] - Maximum numeric value before auto-flush | |
* @property {Function} [onFlush] - Callback function called when events are flushed | |
* @property {boolean} [enableLogging=false] - Enable debug logging to console | |
*/ | |
/** | |
* Options for individual heartbeat calls | |
* @typedef {Object} HeartbeatCallOptions | |
* @property {boolean} [forceFlush=false] - Force immediate flush after aggregation | |
* @property {string} [transport] - Transport method: 'xhr' or 'sendBeacon' | |
*/ | |
/** | |
* Options for flush operations | |
* @typedef {Object} FlushOptions | |
* @property {string} [transport] - Transport method: 'xhr' or 'sendBeacon' | |
*/ | |
/** | |
* Data passed to onFlush callback | |
* @typedef {Object} FlushData | |
* @property {string} eventName - The name of the flushed event | |
* @property {string} contentId - The content ID of the flushed event | |
* @property {Object} props - The aggregated properties sent to Mixpanel | |
* @property {string} reason - The reason for flushing (e.g., 'manual', 'maxBufferTime', 'contentSwitch') | |
* @property {string} transport - The transport method used ('xhr' or 'sendBeacon') | |
*/ | |
/** | |
* Main heartbeat tracking function | |
* @function HeartbeatAPI | |
* @memberof HeartbeatAPI | |
* @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 {HeartbeatCallOptions} [options={}] - Call-specific options | |
* @returns {Object} The heartbeat API object for method chaining | |
* | |
* @description | |
* Aggregates events by eventName and contentId. Properties are merged according to type: | |
* - Numbers: Added together (duration: 30 + duration: 45 = duration: 75) | |
* - Strings: Latest value replaces previous (platform: 'mobile' replaces platform: 'web') | |
* - Objects: Shallow merge with overwrites ({ a: 1, b: 2 } + { b: 3, c: 4 } = { a: 1, b: 3, c: 4 }) | |
* - Arrays: Concatenated (['a', 'b'] + ['c', 'd'] = ['a', 'b', 'c', 'd']) | |
* | |
* @example | |
* // Basic usage | |
* mixpanel.heartbeat('podcast_listen', 'episode_123', { | |
* duration: 30, | |
* platform: 'web' | |
* }); | |
* | |
* @example | |
* // With force flush and custom transport | |
* mixpanel.heartbeat('video_complete', 'video_456', | |
* { completion_rate: 100 }, | |
* { forceFlush: true, transport: 'sendBeacon' } | |
* ); | |
*/ | |
/** | |
* Flushes stored events manually | |
* @function flush | |
* @memberof HeartbeatAPI | |
* @param {string} [eventName] - Flush only events with this name | |
* @param {string} [contentId] - Flush only this specific event (requires eventName) | |
* @param {FlushOptions} [options={}] - Flush options | |
* @returns {Object} The heartbeat API object for method chaining | |
* | |
* @example | |
* // Flush all events | |
* mixpanel.heartbeat().flush(); | |
* | |
* @example | |
* // Flush specific event with sendBeacon | |
* mixpanel.heartbeat().flush('video_watch', 'video_123', { transport: 'sendBeacon' }); | |
*/ | |
/** | |
* Flushes all events for a specific content ID across all event types | |
* @function flushByContentId | |
* @memberof HeartbeatAPI | |
* @param {string} contentId - The content ID to flush | |
* @param {FlushOptions} [options={}] - Flush options | |
* @returns {Object} The heartbeat API object for method chaining | |
* | |
* @example | |
* mixpanel.heartbeat().flushByContentId('episode_123'); | |
*/ | |
/** | |
* Gets the current state of all stored events (for debugging) | |
* @function getState | |
* @memberof HeartbeatAPI | |
* @returns {Object} Object with event keys and their aggregated data | |
* | |
* @example | |
* const currentState = mixpanel.heartbeat().getState(); | |
* console.log('Pending events:', Object.keys(currentState).length); | |
*/ | |
/** | |
* Clears all stored events without flushing them | |
* @function clear | |
* @memberof HeartbeatAPI | |
* @returns {Object} The heartbeat API object for method chaining | |
* | |
* @example | |
* mixpanel.heartbeat().clear(); // Discards all pending events | |
*/ | |
/** | |
* Gets the current configuration | |
* @function getConfig | |
* @memberof HeartbeatAPI | |
* @returns {HeartbeatConfig} Current configuration object | |
* | |
* @example | |
* const config = mixpanel.heartbeat().getConfig(); | |
* console.log('Max buffer time:', config.maxBufferTime); | |
*/ | |
/** | |
* Updates the configuration (allows partial updates) | |
* @function configure | |
* @memberof HeartbeatAPI | |
* @param {Partial<HeartbeatConfig>} newConfig - Configuration updates | |
* @returns {Object} The heartbeat API object for method chaining | |
* | |
* @example | |
* mixpanel.heartbeat().configure({ | |
* maxBufferTime: 60000, // 1 minute | |
* enableLogging: true | |
* });// Mixpanel Heartbeat Extension | |
// Augments Mixpanel SDK with heartbeat functionality for event aggregation | |
mixpanel.init('your-token-here', { | |
loaded: function(mixpanel) { | |
if (!mixpanel.heartbeat) { | |
// Initialize with custom configuration | |
mixpanel.heartbeat = createHeartbeatAPI(mixpanel, { | |
maxBufferTime: 300000, // 5 minutes | |
maxPropsCount: 1000, // Max properties per event | |
maxAggregatedValue: 100000, // Max numeric aggregation | |
enableLogging: true, // Enable debug logging | |
onFlush: function(flushData) { | |
console.log('Event flushed:', flushData); | |
} | |
}); | |
} | |
} | |
}); | |
/** | |
* Creates the heartbeat API with configuration options | |
* @param {Object} mixpanelInstance - The Mixpanel instance | |
* @param {Object} [options={}] - Configuration options | |
* @param {number} [options.maxBufferTime=300000] - Max time in ms before auto-flush (5 mins default) | |
* @param {number} [options.maxPropsCount=1000] - Max aggregated properties before auto-flush | |
* @param {number} [options.maxAggregatedValue=100000] - Max numeric value before auto-flush | |
* @param {Function} [options.onFlush] - Callback when events are flushed | |
* @param {boolean} [options.enableLogging=false] - Enable debug logging | |
* @returns {Function} The heartbeat API function | |
*/ | |
function createHeartbeatAPI(mixpanelInstance, options = {}) { | |
// Configuration with defaults | |
const config = { | |
maxBufferTime: 300000, // 5 minutes | |
maxPropsCount: 1000, // Max properties per event | |
maxAggregatedValue: 100000, // Max numeric aggregation | |
onFlush: null, // Flush callback | |
enableLogging: false, // Debug logging | |
...options | |
}; | |
// Internal storage for aggregated events | |
const eventStore = new Map(); | |
// Track timers for auto-flushing | |
const flushTimers = new Map(); | |
// Track if we've already set up page unload handlers | |
let unloadHandlersSet = false; | |
function log(...args) { | |
if (config.enableLogging) { | |
console.log('[Mixpanel Heartbeat]', ...args); | |
} | |
} | |
function setupUnloadHandlers() { | |
if (unloadHandlersSet) return; | |
unloadHandlersSet = true; | |
// Handle page unload with sendBeacon for better reliability | |
const handleUnload = () => { | |
log('Page unload detected, flushing all events'); | |
flushAll(true, 'pageUnload'); // Pass true to use sendBeacon | |
}; | |
// Multiple event handlers for cross-browser compatibility | |
window.addEventListener('beforeunload', handleUnload); | |
window.addEventListener('pagehide', handleUnload); | |
window.addEventListener('visibilitychange', () => { | |
if (document.visibilityState === 'hidden') { | |
handleUnload(); | |
} | |
}); | |
} | |
function aggregateProps(existingProps, newProps) { | |
const result = { ...existingProps }; | |
for (const [key, newValue] of Object.entries(newProps)) { | |
if (!(key in result)) { | |
// New property, just add it | |
result[key] = newValue; | |
} else { | |
const existingValue = result[key]; | |
const newType = typeof newValue; | |
const 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 (Array.isArray(newValue) && Array.isArray(existingValue)) { | |
// Concatenate arrays | |
result[key] = [...existingValue, ...newValue]; | |
} else if (!Array.isArray(newValue) && !Array.isArray(existingValue)) { | |
// Merge objects (shallow merge with overwrites) | |
result[key] = { ...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 | |
* @param {string} eventKey - The event key | |
*/ | |
function clearFlushTimer(eventKey) { | |
if (flushTimers.has(eventKey)) { | |
clearTimeout(flushTimers.get(eventKey)); | |
flushTimers.delete(eventKey); | |
log('Cleared flush timer for', eventKey); | |
} | |
} | |
/** | |
* Sets up auto-flush timer for a specific event | |
* @param {string} eventKey - The event key | |
*/ | |
function setupFlushTimer(eventKey) { | |
clearFlushTimer(eventKey); | |
const timerId = setTimeout(() => { | |
log('Auto-flushing due to maxBufferTime for', eventKey); | |
flushEvent(eventKey, false, 'maxBufferTime'); | |
}, config.maxBufferTime); | |
flushTimers.set(eventKey, timerId); | |
} | |
/** | |
* Checks if event should be auto-flushed based on limits | |
* @param {Object} eventData - The event data | |
* @returns {string|null} The reason for flushing or null | |
*/ | |
function checkFlushLimits(eventData) { | |
const { props } = eventData; | |
// Check property count | |
const propCount = Object.keys(props).length; | |
if (propCount >= config.maxPropsCount) { | |
return 'maxPropsCount'; | |
} | |
// Check aggregated numeric values | |
for (const [key, value] of Object.entries(props)) { | |
if (typeof value === 'number' && Math.abs(value) >= config.maxAggregatedValue) { | |
return 'maxAggregatedValue'; | |
} | |
} | |
return null; | |
} | |
/** | |
* Flushes a single event | |
* @param {string} eventKey - The event key to flush | |
* @param {boolean} [useSendBeacon=false] - Whether to use sendBeacon transport | |
* @param {string} [reason='manual'] - The reason for flushing | |
*/ | |
function flushEvent(eventKey, useSendBeacon = false, reason = 'manual') { | |
const eventData = eventStore.get(eventKey); | |
if (!eventData) return; | |
const { eventName, contentId, props } = eventData; | |
const trackingProps = { contentId, ...props }; | |
// Clear any pending timers | |
clearFlushTimer(eventKey); | |
// Prepare transport options | |
const transportOptions = useSendBeacon ? { transport: 'sendBeacon' } : {}; | |
try { | |
mixpanelInstance.track(eventName, trackingProps, transportOptions); | |
log('Flushed event', eventKey, 'reason:', reason, 'props:', trackingProps); | |
// Call onFlush callback if provided | |
if (config.onFlush && typeof config.onFlush === 'function') { | |
config.onFlush({ | |
eventName, | |
contentId, | |
props: trackingProps, | |
reason, | |
transport: useSendBeacon ? 'sendBeacon' : 'xhr' | |
}); | |
} | |
} catch (error) { | |
console.error('[Mixpanel Heartbeat] Error flushing event:', error); | |
} | |
// Remove from store after flushing | |
eventStore.delete(eventKey); | |
} | |
/** | |
* Flushes all events | |
* @param {boolean} [useSendBeacon=false] - Whether to use sendBeacon transport | |
* @param {string} [reason='manual'] - The reason for flushing | |
*/ | |
function flushAll(useSendBeacon = false, reason = 'manual') { | |
const keys = Array.from(eventStore.keys()); | |
log('Flushing all events, count:', keys.length, 'reason:', reason); | |
keys.forEach(key => flushEvent(key, useSendBeacon, reason)); | |
} | |
/** | |
* Flushes events by content ID | |
* @param {string} contentId - The content ID to flush | |
* @param {boolean} [useSendBeacon=false] - Whether to use sendBeacon transport | |
* @param {string} [reason='manual'] - The reason for flushing | |
*/ | |
function flushByContentId(contentId, useSendBeacon = false, reason = 'manual') { | |
const keysToFlush = Array.from(eventStore.keys()).filter(key => { | |
const [, storedContentId] = key.split('|'); | |
return storedContentId === contentId; | |
}); | |
log('Flushing by contentId', contentId, 'count:', keysToFlush.length, 'reason:', reason); | |
keysToFlush.forEach(key => flushEvent(key, useSendBeacon, reason)); | |
} | |
/** | |
* Main heartbeat function for tracking events | |
* @param {string} eventName - The name of the event to track | |
* @param {string} contentId - Unique identifier for the content | |
* @param {Object} [props={}] - Properties to aggregate | |
* @param {Object} [options={}] - Call-specific options | |
* @param {boolean} [options.forceFlush=false] - Force immediate flush after aggregation | |
* @param {string} [options.transport] - Transport method ('xhr' or 'sendBeacon') | |
* @returns {Object} The heartbeat API object for chaining | |
*/ | |
function heartbeat(eventName, contentId, props = {}, options = {}) { | |
// Set up unload handlers on first use | |
setupUnloadHandlers(); | |
// If called with no parameters, flush all events | |
if (arguments.length === 0) { | |
flushAll(false, 'manualFlushCall'); | |
return heartbeatAPI; | |
} | |
// Validate required parameters | |
if (!eventName || !contentId) { | |
console.warn('[Mixpanel Heartbeat] eventName and contentId are required'); | |
return heartbeatAPI; | |
} | |
const eventKey = `${eventName}|${contentId}`; | |
log('Heartbeat called for', eventKey, 'props:', props); | |
// Check if this is a new contentId for this eventName | |
// If so, flush any existing events for this eventName with different contentIds | |
const existingKeysForEvent = Array.from(eventStore.keys()).filter(key => { | |
const [storedEventName, storedContentId] = key.split('|'); | |
return storedEventName === eventName && storedContentId !== contentId; | |
}); | |
if (existingKeysForEvent.length > 0) { | |
log('Content switch detected, flushing previous content'); | |
existingKeysForEvent.forEach(key => flushEvent(key, false, 'contentSwitch')); | |
} | |
// Get or create event data | |
if (eventStore.has(eventKey)) { | |
// Aggregate with existing data | |
const existingData = eventStore.get(eventKey); | |
const aggregatedProps = aggregateProps(existingData.props, props); | |
eventStore.set(eventKey, { | |
eventName, | |
contentId, | |
props: aggregatedProps | |
}); | |
log('Aggregated props for', eventKey, 'new props:', aggregatedProps); | |
} else { | |
// Create new entry | |
eventStore.set(eventKey, { | |
eventName, | |
contentId, | |
props: { ...props } | |
}); | |
log('Created new event entry for', eventKey); | |
} | |
const updatedEventData = eventStore.get(eventKey); | |
// Check if we should auto-flush based on limits | |
const flushReason = checkFlushLimits(updatedEventData); | |
if (flushReason) { | |
log('Auto-flushing due to limit:', flushReason); | |
flushEvent(eventKey, options.transport === 'sendBeacon', flushReason); | |
} else if (options.forceFlush) { | |
log('Force flushing requested'); | |
flushEvent(eventKey, options.transport === 'sendBeacon', 'forceFlush'); | |
} else { | |
// Set up or reset the auto-flush timer | |
setupFlushTimer(eventKey); | |
} | |
return heartbeatAPI; | |
} | |
// API object with additional methods | |
const heartbeatAPI = Object.assign(heartbeat, { | |
/** | |
* Flushes events manually | |
* @param {string} [eventName] - Specific event name to flush | |
* @param {string} [contentId] - Specific content ID to flush | |
* @param {Object} [options={}] - Flush options | |
* @param {string} [options.transport] - Transport method ('xhr' or 'sendBeacon') | |
* @returns {Object} The heartbeat API object for chaining | |
*/ | |
flush: function (eventName, contentId, options = {}) { | |
const useSendBeacon = options.transport === 'sendBeacon'; | |
if (eventName && contentId) { | |
// Flush specific event | |
const eventKey = `${eventName}|${contentId}`; | |
flushEvent(eventKey, useSendBeacon, 'manualFlush'); | |
} else if (eventName) { | |
// Flush all events with this eventName | |
const keysToFlush = Array.from(eventStore.keys()).filter(key => | |
key.startsWith(`${eventName}|`) | |
); | |
keysToFlush.forEach(key => flushEvent(key, useSendBeacon, 'manualFlush')); | |
} else { | |
// Flush all events | |
flushAll(useSendBeacon, 'manualFlush'); | |
} | |
return heartbeatAPI; | |
}, | |
/** | |
* Flushes all events for a specific content ID | |
* @param {string} contentId - The content ID to flush | |
* @param {Object} [options={}] - Flush options | |
* @param {string} [options.transport] - Transport method ('xhr' or 'sendBeacon') | |
* @returns {Object} The heartbeat API object for chaining | |
*/ | |
flushByContentId: function (contentId, options = {}) { | |
const useSendBeacon = options.transport === 'sendBeacon'; | |
flushByContentId(contentId, useSendBeacon, 'manualFlushByContentId'); | |
return heartbeatAPI; | |
}, | |
/** | |
* Gets the current state of all stored events (for debugging) | |
* @returns {Object} Current event state | |
*/ | |
getState: function () { | |
const state = {}; | |
eventStore.forEach((value, key) => { | |
state[key] = { ...value }; | |
}); | |
return state; | |
}, | |
/** | |
* Clears all stored events without flushing | |
* @returns {Object} The heartbeat API object for chaining | |
*/ | |
clear: function () { | |
// Clear all timers | |
flushTimers.forEach(timerId => clearTimeout(timerId)); | |
flushTimers.clear(); | |
eventStore.clear(); | |
log('Cleared all events and timers'); | |
return heartbeatAPI; | |
}, | |
/** | |
* Gets the current configuration | |
* @returns {Object} Current configuration | |
*/ | |
getConfig: function () { | |
return { ...config }; | |
}, | |
/** | |
* Updates configuration (partial updates allowed) | |
* @param {Object} newConfig - New configuration options | |
* @returns {Object} The heartbeat API object for chaining | |
*/ | |
configure: function (newConfig) { | |
Object.assign(config, newConfig); | |
log('Configuration updated:', config); | |
return heartbeatAPI; | |
} | |
}); | |
return heartbeatAPI; | |
} | |
// ========================================== | |
// USAGE EXAMPLES | |
// ========================================== | |
// Example 1: Basic podcast listening tracking | |
mixpanel.heartbeat('podcast_listen', 'episode_123', { | |
duration: 30, // seconds listened | |
platform: 'web', | |
user_agent: 'chrome' | |
}); | |
// Subsequent calls aggregate the duration | |
mixpanel.heartbeat('podcast_listen', 'episode_123', { | |
duration: 45, // This gets added to previous 30 = 75 total | |
quality: 'high' // New string property | |
}); | |
// Example 2: Video playback with complex properties | |
mixpanel.heartbeat('video_watch', 'video_456', { | |
watch_time: 120, | |
interactions: ['play', 'pause'], | |
metadata: { | |
resolution: '1080p', | |
bitrate: 5000 | |
} | |
}); | |
mixpanel.heartbeat('video_watch', 'video_456', { | |
watch_time: 60, // Added to previous: 180 total | |
interactions: ['seek', 'volume'], // Appended to array | |
metadata: { | |
buffering_events: 2, // Merged into metadata object | |
resolution: '720p' // Overwrites previous resolution | |
} | |
}); | |
// Example 3: Article reading progress | |
mixpanel.heartbeat('article_read', 'article_789', { | |
scroll_depth: 25, | |
time_spent: 30, | |
sections_viewed: ['intro'] | |
}); | |
mixpanel.heartbeat('article_read', 'article_789', { | |
scroll_depth: 50, // Replaces previous (string-like behavior for percentages) | |
time_spent: 45, // Added to previous: 75 total | |
sections_viewed: ['methodology'] // Appended to array | |
}); | |
// Example 4: Manual flushing | |
mixpanel.heartbeat('podcast_listen', 'episode_123', { duration: 30 }); | |
mixpanel.heartbeat().flush(); // Flush all events | |
// Or flush specific events | |
mixpanel.heartbeat().flush('podcast_listen', 'episode_123'); | |
// Or flush by content ID | |
mixpanel.heartbeat().flushByContentId('episode_123'); | |
// Example 5: Content switching (automatic flush) | |
mixpanel.heartbeat('podcast_listen', 'episode_123', { duration: 120 }); | |
// User switches to a different episode - this will automatically flush episode_123 | |
mixpanel.heartbeat('podcast_listen', 'episode_456', { duration: 30 }); | |
// Example 7: Using transport options and force flush | |
mixpanel.heartbeat('critical_event', 'important_content', | |
{ action: 'purchase', amount: 99.99 }, | |
{ forceFlush: true, transport: 'sendBeacon' } | |
); | |
// Example 8: Configuration and debugging | |
console.log('Current config:', mixpanel.heartbeat().getConfig()); | |
// Update configuration | |
mixpanel.heartbeat().configure({ | |
maxBufferTime: 60000, // Reduce to 1 minute | |
enableLogging: true | |
}); | |
// Example 9: Large aggregation triggering auto-flush | |
for (let i = 0; i < 50; i++) { | |
mixpanel.heartbeat('heavy_usage', 'session_1', { | |
clicks: 20, // This will aggregate to 1000 and trigger auto-flush | |
data: `chunk_${i}` // This will keep replacing | |
}); | |
} | |
// Example 10: Using onFlush callback for monitoring | |
mixpanel.heartbeat = createHeartbeatAPI(mixpanel, { | |
onFlush: (flushData) => { | |
console.log(`Flushed ${flushData.eventName} for ${flushData.contentId}`); | |
console.log(`Reason: ${flushData.reason}, Transport: ${flushData.transport}`); | |
// Send to monitoring system | |
if (flushData.reason === 'maxAggregatedValue') { | |
console.warn('Event reached maximum aggregated value'); | |
} | |
}, | |
enableLogging: process.env.NODE_ENV === 'development' | |
}); | |
// ========================================== | |
// DOCUMENTATION | |
// ========================================== | |
/** | |
* Mixpanel Heartbeat API | |
* | |
* Aggregates small events into summary events before sending to Mixpanel. | |
* Useful for tracking continuous activities like video/audio playback, reading progress, etc. | |
* | |
* SETUP: | |
* Add to your Mixpanel initialization: | |
* | |
* mixpanel.init('your-token', { | |
* loaded: function(mixpanel) { | |
* if (!mixpanel.heartbeat) { | |
* mixpanel.heartbeat = createHeartbeatAPI(mixpanel); | |
* } | |
* } | |
* }); | |
* | |
* BASIC USAGE: | |
* mixpanel.heartbeat(eventName, contentId, props) | |
* | |
* @param {string} eventName - The name of the event (e.g., 'video_watch', 'podcast_listen') | |
* @param {string} contentId - Unique identifier for the content being tracked | |
* @param {object} props - Properties to aggregate (optional) | |
* | |
* PROPERTY AGGREGATION RULES: | |
* - Numbers: Added together (duration: 30 + duration: 45 = duration: 75) | |
* - Strings: Latest value replaces previous (platform: 'mobile' replaces platform: 'web') | |
* - Objects: Shallow merge with overwrites ({ a: 1, b: 2 } + { b: 3, c: 4 } = { a: 1, b: 3, c: 4 }) | |
* - Arrays: Concatenated (['a', 'b'] + ['c', 'd'] = ['a', 'b', 'c', 'd']) | |
* | |
* AUTOMATIC FLUSHING: | |
* - When contentId changes for the same eventName (user switches content) | |
* - On page unload/beforeunload/pagehide/visibilitychange (uses sendBeacon) | |
* | |
* MANUAL FLUSHING: | |
* - mixpanel.heartbeat().flush() - Flush all stored events | |
* - mixpanel.heartbeat().flush(eventName) - Flush all events with specific eventName | |
* - mixpanel.heartbeat().flush(eventName, contentId) - Flush specific event | |
* - mixpanel.heartbeat().flushByContentId(contentId) - Flush all events for specific content | |
* - mixpanel.heartbeat() - Called with no params also flushes all events | |
* | |
* DEBUGGING: | |
* - mixpanel.heartbeat().getState() - Returns current stored events | |
* - mixpanel.heartbeat().clear() - Clears all stored events without flushing | |
* | |
* The final event sent to Mixpanel will be: | |
* mixpanel.track(eventName, { contentId, ...aggregatedProps }) | |
* | |
* CROSS-BROWSER COMPATIBILITY: | |
* - Uses multiple unload event listeners for better browser support | |
* - Automatically uses sendBeacon for page unload scenarios | |
* - Handles visibility changes for mobile/tab switching scenarios | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment