Skip to content

Instantly share code, notes, and snippets.

@ak--47
Created June 11, 2025 20:08
Show Gist options
  • Save ak--47/301f8a60ef3af2fef593e958cc34ef2c to your computer and use it in GitHub Desktop.
Save ak--47/301f8a60ef3af2fef593e958cc34ef2c to your computer and use it in GitHub Desktop.
Mixpanel Heartbeat API
/**
* @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