UNPKG

mixpanel-browser

Version:

The official Mixpanel JavaScript browser client library

530 lines (460 loc) 20.5 kB
import { _, console_with_prefix, generateTraceparent, safewrapClass } from '../utils'; // eslint-disable-line camelcase import { window } from '../window'; import Config from '../config'; import { getTargetingPromise } from '../targeting/loader'; import { TARGETING_GLOBAL_NAME } from '../globals'; var logger = console_with_prefix('flags'); var FLAGS_CONFIG_KEY = 'flags'; var CONFIG_CONTEXT = 'context'; var CONFIG_DEFAULTS = {}; CONFIG_DEFAULTS[CONFIG_CONTEXT] = {}; /** * Generate a unique key for a pending first-time event * @param {string} flagKey - The flag key * @param {string} firstTimeEventHash - The first_time_event_hash from the pending event definition * @returns {string} Composite key in format "flagKey:firstTimeEventHash" */ var getPendingEventKey = function(flagKey, firstTimeEventHash) { return flagKey + ':' + firstTimeEventHash; }; /** * Extract the flag key from a pending event key * @param {string} eventKey - The composite event key in format "flagKey:firstTimeEventHash" * @returns {string} The flag key portion */ var getFlagKeyFromPendingEventKey = function(eventKey) { return eventKey.split(':')[0]; }; /** * FeatureFlagManager: support for Mixpanel's feature flagging product * @constructor */ var FeatureFlagManager = function(initOptions) { this.fetch = window['fetch']; this.getFullApiRoute = initOptions.getFullApiRoute; this.getMpConfig = initOptions.getConfigFunc; this.setMpConfig = initOptions.setConfigFunc; this.getMpProperty = initOptions.getPropertyFunc; this.track = initOptions.trackingFunc; this.loadExtraBundle = initOptions.loadExtraBundle || function() {}; this.targetingSrc = initOptions.targetingSrc || ''; }; FeatureFlagManager.prototype.init = function() { if (!this.minApisSupported()) { logger.critical('Feature Flags unavailable: missing minimum required APIs'); return; } this.flags = null; this.fetchFlags(); this.trackedFeatures = new Set(); this.pendingFirstTimeEvents = {}; this.activatedFirstTimeEvents = {}; }; FeatureFlagManager.prototype.getFullConfig = function() { var ffConfig = this.getMpConfig(FLAGS_CONFIG_KEY); if (!ffConfig) { // flags are completely off return {}; } else if (_.isObject(ffConfig)) { return _.extend({}, CONFIG_DEFAULTS, ffConfig); } else { // config is non-object truthy value, return default return CONFIG_DEFAULTS; } }; FeatureFlagManager.prototype.getConfig = function(key) { return this.getFullConfig()[key]; }; FeatureFlagManager.prototype.isSystemEnabled = function() { return !!this.getMpConfig(FLAGS_CONFIG_KEY); }; FeatureFlagManager.prototype.updateContext = function(newContext, options) { if (!this.isSystemEnabled()) { logger.critical('Feature Flags not enabled, cannot update context'); return Promise.resolve(); } var ffConfig = this.getMpConfig(FLAGS_CONFIG_KEY); if (!_.isObject(ffConfig)) { ffConfig = {}; } var oldContext = (options && options['replace']) ? {} : this.getConfig(CONFIG_CONTEXT); ffConfig[CONFIG_CONTEXT] = _.extend({}, oldContext, newContext); this.setMpConfig(FLAGS_CONFIG_KEY, ffConfig); return this.fetchFlags(); }; FeatureFlagManager.prototype.areFlagsReady = function() { if (!this.isSystemEnabled()) { logger.error('Feature Flags not enabled'); } return !!this.flags; }; FeatureFlagManager.prototype.fetchFlags = function() { if (!this.isSystemEnabled()) { return Promise.resolve(); } var distinctId = this.getMpProperty('distinct_id'); var deviceId = this.getMpProperty('$device_id'); var traceparent = generateTraceparent(); logger.log('Fetching flags for distinct ID: ' + distinctId); var context = _.extend({'distinct_id': distinctId, 'device_id': deviceId}, this.getConfig(CONFIG_CONTEXT)); var searchParams = new URLSearchParams(); searchParams.set('context', JSON.stringify(context)); searchParams.set('token', this.getMpConfig('token')); searchParams.set('mp_lib', 'web'); searchParams.set('$lib_version', Config.LIB_VERSION); var url = this.getFullApiRoute() + '?' + searchParams.toString(); this._fetchInProgressStartTime = Date.now(); this.fetchPromise = this.fetch.call(window, url, { 'method': 'GET', 'headers': { 'Authorization': 'Basic ' + btoa(this.getMpConfig('token') + ':'), 'traceparent': traceparent } }).then(function(response) { this.markFetchComplete(); return response.json().then(function(responseBody) { var responseFlags = responseBody['flags']; if (!responseFlags) { throw new Error('No flags in API response'); } var flags = new Map(); var pendingFirstTimeEvents = {}; // Process flags from response _.each(responseFlags, function(data, key) { // Check if this flag has any activated first-time events this session var hasActivatedEvent = false; var prefix = key + ':'; _.each(this.activatedFirstTimeEvents, function(activated, eventKey) { if (eventKey.startsWith(prefix)) { hasActivatedEvent = true; } }); if (hasActivatedEvent) { // Preserve the activated variant, don't overwrite with server's current variant var currentFlag = this.flags && this.flags.get(key); if (currentFlag) { flags.set(key, currentFlag); } } else { // Use server's current variant flags.set(key, { 'key': data['variant_key'], 'value': data['variant_value'], 'experiment_id': data['experiment_id'], 'is_experiment_active': data['is_experiment_active'], 'is_qa_tester': data['is_qa_tester'] }); } }, this); // Process top-level pending_first_time_events array var topLevelDefinitions = responseBody['pending_first_time_events']; if (topLevelDefinitions && topLevelDefinitions.length > 0) { _.each(topLevelDefinitions, function(def) { var flagKey = def['flag_key']; var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']); // Skip if this specific event has already been activated this session if (this.activatedFirstTimeEvents[eventKey]) { return; } // Store pending event definition using composite key pendingFirstTimeEvents[eventKey] = { 'flag_key': flagKey, 'flag_id': def['flag_id'], 'project_id': def['project_id'], 'first_time_event_hash': def['first_time_event_hash'], 'event_name': def['event_name'], 'property_filters': def['property_filters'], 'pending_variant': def['pending_variant'] }; }, this); } // Preserve any activated orphaned flags (flags that were activated but are no longer in response) if (this.activatedFirstTimeEvents) { _.each(this.activatedFirstTimeEvents, function(activated, eventKey) { var flagKey = getFlagKeyFromPendingEventKey(eventKey); if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) { // Keep the activated flag even though it's not in the new response flags.set(flagKey, this.flags.get(flagKey)); } }, this); } this.flags = flags; this.pendingFirstTimeEvents = pendingFirstTimeEvents; this._traceparent = traceparent; this._loadTargetingIfNeeded(); }.bind(this)).catch(function(error) { this.markFetchComplete(); logger.error(error); }.bind(this)); }.bind(this)).catch(function(error) { this.markFetchComplete(); logger.error(error); }.bind(this)); return this.fetchPromise; }; FeatureFlagManager.prototype.markFetchComplete = function() { if (!this._fetchInProgressStartTime) { logger.error('Fetch in progress started time not set, cannot mark fetch complete'); return; } this._fetchStartTime = this._fetchInProgressStartTime; this._fetchCompleteTime = Date.now(); this._fetchLatency = this._fetchCompleteTime - this._fetchStartTime; this._fetchInProgressStartTime = null; }; /** * Proactively load targeting bundle if any pending events have property filters */ FeatureFlagManager.prototype._loadTargetingIfNeeded = function() { var hasPropertyFilters = false; _.each(this.pendingFirstTimeEvents, function(evt) { if (evt['property_filters'] && !_.isEmptyObject(evt['property_filters'])) { hasPropertyFilters = true; } }); if (hasPropertyFilters) { this.getTargeting().then(function() { logger.log('targeting loaded for property filter evaluation'); }); } }; /** * Get the targeting library (initializes if not already loaded) * This method is primarily for testing - production code should rely on automatic loading * @returns {Promise} Promise that resolves with targeting library */ FeatureFlagManager.prototype.getTargeting = function() { return getTargetingPromise( this.loadExtraBundle.bind(this), this.targetingSrc ).catch(function(error) { logger.error('Failed to load targeting: ' + error); }.bind(this)); }; /** * Check if a tracked event matches any pending first-time events and activate the corresponding flag variant * @param {string} eventName - The name of the event being tracked * @param {Object} properties - Event properties to evaluate against property filters * * When a match is found (event name matches and property filters pass), this method: * - Switches the flag to the pending variant * - Marks the event as activated for this session * - Records the activation via the API (fire-and-forget) */ FeatureFlagManager.prototype.checkFirstTimeEvents = function(eventName, properties) { if (!this.pendingFirstTimeEvents || _.isEmptyObject(this.pendingFirstTimeEvents)) { return; } // Check if targeting promise exists (either bundled or async loaded) if (window[TARGETING_GLOBAL_NAME] && _.isFunction(window[TARGETING_GLOBAL_NAME].then)) { window[TARGETING_GLOBAL_NAME].then(function(library) { this._processFirstTimeEventCheck(eventName, properties, library); }.bind(this)).catch(function() { // If targeting failed to load, process with null // Events without property filters will still match this._processFirstTimeEventCheck(eventName, properties, null); }.bind(this)); } else { // No targeting available, process with null // Events without property filters will still match this._processFirstTimeEventCheck(eventName, properties, null); } }; /** * Internal method to process first-time event checks with loaded targeting library * @param {string} eventName - The name of the event being tracked * @param {Object} properties - Event properties to evaluate against property filters * @param {Object} targeting - The loaded targeting library */ FeatureFlagManager.prototype._processFirstTimeEventCheck = function(eventName, properties, targeting) { _.each(this.pendingFirstTimeEvents, function(pendingEvent, eventKey) { if (this.activatedFirstTimeEvents[eventKey]) { return; } var flagKey = pendingEvent['flag_key']; // Use targeting module to check if event matches var matchResult; // If no targeting library and event has property filters, skip it if (!targeting && pendingEvent['property_filters'] && !_.isEmptyObject(pendingEvent['property_filters'])) { logger.warn('Skipping event check for "' + flagKey + '" - property filters require targeting library'); return; } // For simple events (no property filters), just check event name if (!targeting) { matchResult = { matches: eventName === pendingEvent['event_name'], error: null }; } else { var criteria = { 'event_name': pendingEvent['event_name'], 'property_filters': pendingEvent['property_filters'] }; matchResult = targeting['eventMatchesCriteria']( eventName, properties, criteria ); } if (matchResult.error) { logger.error('Error checking first-time event for flag "' + flagKey + '": ' + matchResult.error); return; } if (!matchResult.matches) { return; } logger.log('First-time event matched for flag "' + flagKey + '": ' + eventName); var newVariant = { 'key': pendingEvent['pending_variant']['variant_key'], 'value': pendingEvent['pending_variant']['variant_value'], 'experiment_id': pendingEvent['pending_variant']['experiment_id'], 'is_experiment_active': pendingEvent['pending_variant']['is_experiment_active'] }; this.flags.set(flagKey, newVariant); this.activatedFirstTimeEvents[eventKey] = true; this.recordFirstTimeEvent( pendingEvent['flag_id'], pendingEvent['project_id'], pendingEvent['first_time_event_hash'] ); }, this); }; FeatureFlagManager.prototype.getFirstTimeEventApiRoute = function(flagId) { // Construct URL: {api_host}/flags/{flagId}/first-time-events return this.getFullApiRoute() + '/' + flagId + '/first-time-events'; }; FeatureFlagManager.prototype.recordFirstTimeEvent = function(flagId, projectId, firstTimeEventHash) { var distinctId = this.getMpProperty('distinct_id'); var traceparent = generateTraceparent(); // Build URL with query string parameters var searchParams = new URLSearchParams(); searchParams.set('mp_lib', 'web'); searchParams.set('$lib_version', Config.LIB_VERSION); var url = this.getFirstTimeEventApiRoute(flagId) + '?' + searchParams.toString(); var payload = { 'distinct_id': distinctId, 'project_id': projectId, 'first_time_event_hash': firstTimeEventHash }; logger.log('Recording first-time event for flag: ' + flagId); // Fire-and-forget POST request this.fetch.call(window, url, { 'method': 'POST', 'headers': { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + btoa(this.getMpConfig('token') + ':'), 'traceparent': traceparent }, 'body': JSON.stringify(payload) }).catch(function(error) { // Silent failure - cohort sync will catch up logger.error('Failed to record first-time event for flag ' + flagId + ': ' + error); }); }; FeatureFlagManager.prototype.getVariant = function(featureName, fallback) { if (!this.fetchPromise) { return new Promise(function(resolve) { logger.critical('Feature Flags not initialized'); resolve(fallback); }); } return this.fetchPromise.then(function() { return this.getVariantSync(featureName, fallback); }.bind(this)).catch(function(error) { logger.error(error); return fallback; }); }; FeatureFlagManager.prototype.getVariantSync = function(featureName, fallback) { if (!this.areFlagsReady()) { logger.log('Flags not loaded yet'); return fallback; } var feature = this.flags.get(featureName); if (!feature) { logger.log('No flag found: "' + featureName + '"'); return fallback; } this.trackFeatureCheck(featureName, feature); return feature; }; FeatureFlagManager.prototype.getVariantValue = function(featureName, fallbackValue) { return this.getVariant(featureName, {'value': fallbackValue}).then(function(feature) { return feature['value']; }).catch(function(error) { logger.error(error); return fallbackValue; }); }; // TODO remove deprecated method FeatureFlagManager.prototype.getFeatureData = function(featureName, fallbackValue) { logger.critical('mixpanel.flags.get_feature_data() is deprecated and will be removed in a future release. Use mixpanel.flags.get_variant_value() instead.'); return this.getVariantValue(featureName, fallbackValue); }; FeatureFlagManager.prototype.getVariantValueSync = function(featureName, fallbackValue) { return this.getVariantSync(featureName, {'value': fallbackValue})['value']; }; FeatureFlagManager.prototype.isEnabled = function(featureName, fallbackValue) { return this.getVariantValue(featureName).then(function() { return this.isEnabledSync(featureName, fallbackValue); }.bind(this)).catch(function(error) { logger.error(error); return fallbackValue; }); }; FeatureFlagManager.prototype.isEnabledSync = function(featureName, fallbackValue) { fallbackValue = fallbackValue || false; var val = this.getVariantValueSync(featureName, fallbackValue); if (val !== true && val !== false) { logger.error('Feature flag "' + featureName + '" value: ' + val + ' is not a boolean; returning fallback value: ' + fallbackValue); val = fallbackValue; } return val; }; FeatureFlagManager.prototype.trackFeatureCheck = function(featureName, feature) { if (this.trackedFeatures.has(featureName)) { return; } this.trackedFeatures.add(featureName); var trackingProperties = { 'Experiment name': featureName, 'Variant name': feature['key'], '$experiment_type': 'feature_flag', 'Variant fetch start time': new Date(this._fetchStartTime).toISOString(), 'Variant fetch complete time': new Date(this._fetchCompleteTime).toISOString(), 'Variant fetch latency (ms)': this._fetchLatency, 'Variant fetch traceparent': this._traceparent, }; if (feature['experiment_id'] !== 'undefined') { trackingProperties['$experiment_id'] = feature['experiment_id']; } if (feature['is_experiment_active'] !== 'undefined') { trackingProperties['$is_experiment_active'] = feature['is_experiment_active']; } if (feature['is_qa_tester'] !== 'undefined') { trackingProperties['$is_qa_tester'] = feature['is_qa_tester']; } this.track('$experiment_started', trackingProperties); }; FeatureFlagManager.prototype.minApisSupported = function() { return !!this.fetch && typeof Promise !== 'undefined' && typeof Map !== 'undefined' && typeof Set !== 'undefined'; }; safewrapClass(FeatureFlagManager); FeatureFlagManager.prototype['are_flags_ready'] = FeatureFlagManager.prototype.areFlagsReady; FeatureFlagManager.prototype['get_variant'] = FeatureFlagManager.prototype.getVariant; FeatureFlagManager.prototype['get_variant_sync'] = FeatureFlagManager.prototype.getVariantSync; FeatureFlagManager.prototype['get_variant_value'] = FeatureFlagManager.prototype.getVariantValue; FeatureFlagManager.prototype['get_variant_value_sync'] = FeatureFlagManager.prototype.getVariantValueSync; FeatureFlagManager.prototype['is_enabled'] = FeatureFlagManager.prototype.isEnabled; FeatureFlagManager.prototype['is_enabled_sync'] = FeatureFlagManager.prototype.isEnabledSync; FeatureFlagManager.prototype['update_context'] = FeatureFlagManager.prototype.updateContext; // Deprecated method FeatureFlagManager.prototype['get_feature_data'] = FeatureFlagManager.prototype.getFeatureData; // Exports intended only for testing FeatureFlagManager.prototype['getTargeting'] = FeatureFlagManager.prototype.getTargeting; export { FeatureFlagManager };