UNPKG

mixpanel

Version:

A simple server-side API for mixpanel

395 lines (344 loc) 11.5 kB
/** * Local Feature Flags Provider * Evaluates feature flags client-side using locally cached definitions */ /** * @typedef {import('./types').SelectedVariant} SelectedVariant * @typedef {import('./types').FlagContext} FlagContext * @typedef {import('./types').LocalFlagsConfig} LocalFlagsConfig * @typedef {import('./types').ExperimentationFlag} ExperimentationFlag * @typedef {import('./types').LocalFlagsResponse} LocalFlagsResponse * */ const FeatureFlagsProvider = require("./flags"); const { normalizedHash, lowercaseAllKeysAndValues, lowercaseLeafNodes, } = require("./utils"); const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { /** * @param {string} token - Mixpanel project token * @param {LocalFlagsConfig} config - Local flags configuration * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) * @param {CustomLogger} logger - Logger */ constructor(token, config, tracker, logger) { const mergedConfig = { api_host: "api.mixpanel.com", request_timeout_in_seconds: 10, enable_polling: true, polling_interval_in_seconds: 60, ...config, }; const providerConfig = { token, api_host: mergedConfig.api_host, request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, }; super(providerConfig, "/flags/definitions", tracker, "local", logger); this.config = mergedConfig; this.flagDefinitions = new Map(); this.pollingInterval = null; this._initialFetchPromise = null; } /** * Start polling for flag definitions. * Fetches immediately and then at regular intervals if polling is enabled * @returns {Promise<void>} */ async startPollingForDefinitions() { try { this._initialFetchPromise = this._fetchFlagDefinitions(); await this._initialFetchPromise; if (this.config.enable_polling && !this.pollingInterval) { this.pollingInterval = setInterval(async () => { try { await this._fetchFlagDefinitions(); } catch (err) { this.logger?.error( `Error polling for flag definition: ${err.message}`, ); } }, this.config.polling_interval_in_seconds * 1000); } } catch (err) { this.logger?.error( `Initial flag definitions fetch failed: ${err.message}`, ); } } /** * Stop polling for flag definitions */ stopPollingForDefinitions() { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } else { this.logger?.warn( "stopPollingForDefinitions called but polling was not active", ); } } shutdown() { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } areFlagsReady() { if (!this._initialFetchPromise) { this.logger?.warn( "areFlagsReady called before startPollingForDefinitions", ); } return this._initialFetchPromise ?? Promise.resolve(); } /** * Check if a feature flag is enabled * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) * This checks that the variant value of a selected variant is concretely the boolean 'true' * It does not coerce other truthy values. * @param {string} flagKey - Feature flag key * @param {FlagContext} context - Evaluation context (must include distinct_id) * @returns {boolean} */ isEnabled(flagKey, context) { const value = this.getVariantValue(flagKey, false, context); return value === true; } /** * Get the variant value for a feature flag * @param {string} flagKey - Feature flag key * @param {*} fallbackValue - Value to return if the user context is not in the rollout for a flag or if evaluation fails * @param {FlagContext} context - Evaluation context * @param {boolean} [reportExposure=true] - Whether to track exposure event * @returns {*} The variant value */ getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { const result = this.getVariant( flagKey, { variant_value: fallbackValue }, context, reportExposure, ); return result.variant_value; } /** * Get the complete variant information for a feature flag * @param {string} flagKey - Feature flag key * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails * @param {FlagContext} context - Evaluation context (must include distinct_id) * @param {boolean} [reportExposure=true] - Whether to track exposure event * @returns {SelectedVariant} */ getVariant(flagKey, fallbackVariant, context, reportExposure = true) { const flag = this.flagDefinitions.get(flagKey); if (!flag) { this.logger?.warn(`Cannot find flag definition for key: '${flagKey}`); return fallbackVariant; } if (!Object.hasOwn(context, flag.context)) { this.logger?.warn( `The variant assignment key, '${flag.context}' for flag, '${flagKey}' is not present in the supplied user context dictionary`, ); return fallbackVariant; } const contextValue = context[flag.context]; let selectedVariant = null; const testUserVariant = this._getVariantOverrideForTestUser(flag, context); if (testUserVariant) { selectedVariant = testUserVariant; } else { const rollout = this._getAssignedRollout(flag, contextValue, context); if (rollout) { selectedVariant = this._getAssignedVariant( flag, contextValue, flagKey, rollout, ); } } if (selectedVariant) { if (reportExposure) { this.trackExposureEvent(flagKey, selectedVariant, context); } return selectedVariant; } return fallbackVariant; } /** * Get all feature flag variants for the current user context * Exposure events are not automatically tracked when this method is used * @param {FlagContext} context - Evaluation context (must include distinct_id) * @returns {{[key: string]: SelectedVariant}} */ getAllVariants(context) { const variants = {}; for (const flagKey of this.flagDefinitions.keys()) { const variant = this.getVariant(flagKey, null, context, false); if (variant !== null) { variants[flagKey] = variant; } } return variants; } /** * Fetch flag definitions from API. * @returns {Promise<LocalFlagsResponse>} */ async _fetchFlagDefinitions() { const response = await this.callFlagsEndpoint(); const newDefinitions = new Map(); response.flags.forEach((flag) => { newDefinitions.set(flag.key, flag); }); this.flagDefinitions = newDefinitions; return response; } /** * Find a variant by key (case-insensitive) and return complete SelectedVariant * @param {string} variantKey - Variant key to find * @param {ExperimentationFlag} flag - Flag definition * @returns {SelectedVariant|null} */ _getMatchingVariant(variantKey, flag) { for (const variant of flag.ruleset.variants) { if (variantKey.toLowerCase() === variant.key.toLowerCase()) { return { variant_key: variant.key, variant_value: variant.value, experiment_id: flag.experiment_id, is_experiment_active: flag.is_experiment_active, }; } } return null; } _getVariantOverrideForTestUser(flag, context) { if (!flag.ruleset.test?.users) { return null; } const distinctId = context.distinct_id; if (!distinctId) { return null; } const variantKey = flag.ruleset.test.users[distinctId]; if (!variantKey) { return null; } let selectedVariant = this._getMatchingVariant(variantKey, flag); if (selectedVariant) { selectedVariant.is_qa_tester = true; } return selectedVariant; } _getAssignedRollout(flag, contextValue, context) { for (let index = 0; index < flag.ruleset.rollout.length; index++) { const rollout = flag.ruleset.rollout[index]; let salt; if (flag.hash_salt != null) { salt = flag.key + flag.hash_salt + index.toString(); } else { salt = flag.key + "rollout"; } const rolloutHash = normalizedHash(String(contextValue), salt); if ( rolloutHash < rollout.rollout_percentage && this._isRuntimeEvaluationSatisfied(rollout, context) ) { return rollout; } } return null; } _getAssignedVariant(flag, contextValue, flagKey, rollout) { if (rollout.variant_override) { const variant = this._getMatchingVariant( rollout.variant_override.key, flag, ); if (variant) { return { ...variant, is_qa_tester: false }; } } const storedSalt = flag.hash_salt != null ? flag.hash_salt : ""; const salt = flagKey + storedSalt + "variant"; const variantHash = normalizedHash(String(contextValue), salt); const variants = flag.ruleset.variants.map((v) => ({ ...v })); if (rollout.variant_splits) { for (const variant of variants) { if (variant.key in rollout.variant_splits) { variant.split = rollout.variant_splits[variant.key]; } } } let selected = variants[0]; let cumulative = 0.0; for (const variant of variants) { selected = variant; cumulative += variant.split || 0.0; if (variantHash < cumulative) { break; } } return { variant_key: selected.key, variant_value: selected.value, experiment_id: flag.experiment_id, is_experiment_active: flag.is_experiment_active, is_qa_tester: false, }; } _extractRuntimeParameters(context) { const customProperties = context.custom_properties; if (!customProperties || typeof customProperties !== "object") { return null; } return lowercaseAllKeysAndValues(customProperties); } _isRuntimeRuleSatisfied(rollout, context) { try { return apply( lowercaseLeafNodes(rollout.runtime_evaluation_rule), this._extractRuntimeParameters(context), ); } catch (error) { this.logger?.error(`Error evaluating runtime rule: ${error.message}`); return false; } } _isRuntimeEvaluationSatisfied(rollout, context) { if (rollout.runtime_evaluation_rule) { return this._isRuntimeRuleSatisfied(rollout, context); } else if (rollout.runtime_evaluation_definition) { return this._isLegacyRuntimeEvaluationSatisfied(rollout, context); } else { return true; } } _isLegacyRuntimeEvaluationSatisfied(rollout, context) { const customProperties = this._extractRuntimeParameters(context); if (!customProperties) { return false; } for (const [key, expectedValue] of Object.entries( rollout.runtime_evaluation_definition, )) { if (!(key in customProperties)) { return false; } const actualValue = customProperties[key]; if ( String(actualValue).toLowerCase() !== String(expectedValue).toLowerCase() ) { return false; } } return true; } } module.exports = LocalFeatureFlagsProvider;