mixpanel-react-native
Version:
Official React Native Tracking Library for Mixpanel Analytics
498 lines (437 loc) • 15.3 kB
JavaScript
import { MixpanelLogger } from "./mixpanel-logger";
import { MixpanelNetwork } from "./mixpanel-network";
import { MixpanelPersistent } from "./mixpanel-persistent";
import packageJson from "mixpanel-react-native/package.json";
/**
* JavaScript implementation of Feature Flags for React Native
* This is used when native modules are not available (Expo, React Native Web)
* Aligned with mixpanel-js reference implementation
*/
export class MixpanelFlagsJS {
constructor(token, mixpanelImpl, storage) {
this.token = token;
this.mixpanelImpl = mixpanelImpl;
this.storage = storage;
this.flags = new Map(); // Use Map like mixpanel-js
this.flagsReady = false;
this.experimentTracked = new Set();
this.context = {};
this.flagsCacheKey = `MIXPANEL_${token}_FLAGS_CACHE`;
this.flagsReadyKey = `MIXPANEL_${token}_FLAGS_READY`;
this.mixpanelPersistent = MixpanelPersistent.getInstance(storage, token);
// Performance tracking (mixpanel-js alignment)
this._fetchStartTime = null;
this._fetchCompleteTime = null;
this._fetchLatency = null;
this._traceparent = null;
// Load cached flags on initialization (fire and forget - loads in background)
// This is async but intentionally not awaited to avoid blocking constructor
// Flags will be available once cache loads or after explicit loadFlags() call
this.loadCachedFlags().catch(error => {
MixpanelLogger.log(this.token, "Failed to load cached flags in constructor:", error);
});
}
/**
* Load cached flags from storage
*/
async loadCachedFlags() {
try {
const cachedFlags = await this.storage.getItem(this.flagsCacheKey);
if (cachedFlags) {
const parsed = JSON.parse(cachedFlags);
// Convert array back to Map for consistency
this.flags = new Map(parsed);
this.flagsReady = true;
MixpanelLogger.log(this.token, "Loaded cached feature flags");
}
} catch (error) {
MixpanelLogger.log(this.token, "Error loading cached flags:", error);
}
}
/**
* Cache flags to storage
*/
async cacheFlags() {
try {
// Convert Map to array for JSON serialization
const flagsArray = Array.from(this.flags.entries());
await this.storage.setItem(
this.flagsCacheKey,
JSON.stringify(flagsArray)
);
await this.storage.setItem(this.flagsReadyKey, "true");
} catch (error) {
MixpanelLogger.log(this.token, "Error caching flags:", error);
}
}
/**
* Generate W3C traceparent header
* Format: 00-{traceID}-{parentID}-{flags}
* Returns null if UUID generation fails (graceful degradation)
*/
generateTraceparent() {
// Helper to get crypto object across environments
const getCrypto = () => {
if (typeof globalThis !== "undefined" && globalThis.crypto?.randomUUID) {
return globalThis.crypto;
}
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
return window.crypto;
}
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto;
}
return null;
};
// Try Web Crypto API first (most reliable in browsers)
const webCrypto = getCrypto();
if (webCrypto) {
const traceID = webCrypto.randomUUID().replace(/-/g, "");
const parentID = webCrypto.randomUUID().replace(/-/g, "").substring(0, 16);
return `00-${traceID}-${parentID}-01`;
}
// Try expo-crypto (Expo environments)
try {
const expoCrypto = require("expo-crypto");
const traceID = expoCrypto.randomUUID().replace(/-/g, "");
const parentID = expoCrypto.randomUUID().replace(/-/g, "").substring(0, 16);
return `00-${traceID}-${parentID}-01`;
} catch (e) {
// Not in Expo environment
}
// Try uuid package (React Native with polyfill)
try {
const { v4: uuidv4 } = require("uuid");
const traceID = uuidv4().replace(/-/g, "");
const parentID = uuidv4().replace(/-/g, "").substring(0, 16);
return `00-${traceID}-${parentID}-01`;
} catch (e) {
// uuid not available
}
// Graceful degradation: traceparent is optional for observability
MixpanelLogger.log(this.token, "Could not generate traceparent (no UUID source available)");
return null;
}
/**
* Mark fetch operation complete and calculate latency
*/
markFetchComplete() {
if (!this._fetchStartTime) {
MixpanelLogger.error(
this.token,
"Fetch start time not set, cannot mark fetch complete"
);
return;
}
this._fetchCompleteTime = Date.now();
this._fetchLatency = this._fetchCompleteTime - this._fetchStartTime;
this._fetchStartTime = null;
}
/**
* Fetch feature flags from Mixpanel API
*/
async loadFlags() {
this._fetchStartTime = Date.now();
// Generate traceparent if possible (graceful degradation if UUID unavailable)
try {
this._traceparent = this.generateTraceparent();
} catch (error) {
// Silently skip traceparent if generation fails
this._traceparent = null;
}
try {
const distinctId = this.mixpanelPersistent.getDistinctId(this.token);
const deviceId = this.mixpanelPersistent.getDeviceId(this.token);
// Build context object (mixpanel-js format)
const context = {
distinct_id: distinctId,
device_id: deviceId,
...this.context,
};
// Build query parameters (mixpanel-js format)
const queryParams = new URLSearchParams();
queryParams.set('context', JSON.stringify(context));
queryParams.set('token', this.token);
queryParams.set('mp_lib', 'react-native');
queryParams.set('$lib_version', packageJson.version);
MixpanelLogger.log(
this.token,
"Fetching feature flags with context:",
context
);
const serverURL =
this.mixpanelImpl.config?.getServerURL?.(this.token) ||
"https://api.mixpanel.com";
// Use /flags endpoint with query parameters (mixpanel-js format)
const endpoint = `/flags?${queryParams.toString()}`;
// Build headers with Basic auth (aligned with mixpanel-js)
const headers = {
'Authorization': 'Basic ' + btoa(this.token + ':'),
};
// Add traceparent header if available (for observability)
if (this._traceparent) {
headers['traceparent'] = this._traceparent;
}
const response = await MixpanelNetwork.sendRequest({
token: this.token,
endpoint: endpoint,
data: null, // Data is in query params for flags endpoint
serverURL: serverURL,
useIPAddressForGeoLocation: true,
headers: headers,
});
this.markFetchComplete();
// Support both response formats for backwards compatibility
if (response && response.flags) {
// New format (mixpanel-js compatible): {flags: {key: {variant_key, variant_value, ...}}}
this.flags = new Map();
for (const [key, data] of Object.entries(response.flags)) {
this.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.flagsReady = true;
await this.cacheFlags();
MixpanelLogger.log(this.token, "Feature flags loaded successfully");
} else if (response && response.featureFlags) {
// Legacy format: {featureFlags: [{key, value, experimentID, ...}]}
this.flags = new Map();
for (const flag of response.featureFlags) {
this.flags.set(flag.key, {
key: flag.key,
value: flag.value,
experiment_id: flag.experimentID,
is_experiment_active: flag.isExperimentActive,
is_qa_tester: flag.isQATester,
});
}
this.flagsReady = true;
await this.cacheFlags();
MixpanelLogger.warn(
this.token,
'Received legacy featureFlags format. Please update backend to use "flags" format.'
);
}
} catch (error) {
this.markFetchComplete();
MixpanelLogger.log(this.token, "Error loading feature flags:", error);
// Keep using cached flags if available
if (this.flags.size > 0) {
this.flagsReady = true;
}
// Re-throw so caller knows about the failure
throw error;
}
}
/**
* Check if flags are ready to use
*/
areFlagsReady() {
return this.flagsReady;
}
/**
* Track experiment started event
* Aligned with mixpanel-js tracking properties
*/
async trackExperimentStarted(featureName, variant) {
if (this.experimentTracked.has(featureName)) {
return; // Already tracked
}
try {
const properties = {
"Experiment name": featureName, // Human-readable (mixpanel-js format)
"Variant name": variant.key, // Human-readable (mixpanel-js format)
$experiment_type: "feature_flag", // Added to match mixpanel-js
};
// Add performance metrics if available
if (this._fetchCompleteTime) {
const fetchStartTime =
this._fetchCompleteTime - (this._fetchLatency || 0);
properties["Variant fetch start time"] = new Date(
fetchStartTime
).toISOString();
properties["Variant fetch complete time"] = new Date(
this._fetchCompleteTime
).toISOString();
properties["Variant fetch latency (ms)"] = this._fetchLatency || 0;
}
// Add traceparent if available
if (this._traceparent) {
properties["Variant fetch traceparent"] = this._traceparent;
}
// Add experiment metadata (system properties)
if (
variant.experiment_id !== undefined &&
variant.experiment_id !== null
) {
properties["$experiment_id"] = variant.experiment_id;
}
if (variant.is_experiment_active !== undefined) {
properties["$is_experiment_active"] = variant.is_experiment_active;
}
if (variant.is_qa_tester !== undefined) {
properties["$is_qa_tester"] = variant.is_qa_tester;
}
// Track the experiment started event
await this.mixpanelImpl.track(
this.token,
"$experiment_started",
properties
);
this.experimentTracked.add(featureName);
MixpanelLogger.log(
this.token,
`Tracked experiment started for ${featureName}`
);
} catch (error) {
MixpanelLogger.log(this.token, "Error tracking experiment:", error);
}
}
/**
* Get variant synchronously (only works when flags are ready)
*/
getVariantSync(featureName, fallback) {
if (!this.flagsReady || !this.flags.has(featureName)) {
return fallback;
}
const variant = this.flags.get(featureName);
// Track experiment on first access (fire and forget)
if (!this.experimentTracked.has(featureName)) {
this.trackExperimentStarted(featureName, variant).catch(() => {});
}
return variant;
}
/**
* Get variant value synchronously
*/
getVariantValueSync(featureName, fallbackValue) {
const variant = this.getVariantSync(featureName, {
key: featureName,
value: fallbackValue,
});
return variant.value;
}
/**
* Check if feature is enabled synchronously
* Enhanced with boolean validation like mixpanel-js
*/
isEnabledSync(featureName, fallbackValue = false) {
const value = this.getVariantValueSync(featureName, fallbackValue);
// Validate boolean type (mixpanel-js pattern)
if (value !== true && value !== false) {
MixpanelLogger.error(
this.token,
`Feature flag "${featureName}" value: ${value} is not a boolean; returning fallback value: ${fallbackValue}`
);
return fallbackValue;
}
return value;
}
/**
* Get variant asynchronously
*/
async getVariant(featureName, fallback) {
// If flags not ready, try to load them
if (!this.flagsReady) {
await this.loadFlags();
}
if (!this.flags.has(featureName)) {
return fallback;
}
const variant = this.flags.get(featureName);
// Track experiment on first access
if (!this.experimentTracked.has(featureName)) {
await this.trackExperimentStarted(featureName, variant);
}
return variant;
}
/**
* Get variant value asynchronously
*/
async getVariantValue(featureName, fallbackValue) {
const variant = await this.getVariant(featureName, {
key: featureName,
value: fallbackValue,
});
return variant.value;
}
/**
* Check if feature is enabled asynchronously
*/
async isEnabled(featureName, fallbackValue = false) {
const value = await this.getVariantValue(featureName, fallbackValue);
if (typeof value === "boolean") {
return value;
} else {
MixpanelLogger.log(this.token, `Flag "${featureName}" value is not boolean:`, value);
return fallbackValue;
}
}
/**
* Update context and reload flags
* Aligned with mixpanel-js API signature
* @param {object} newContext - New context properties to add/update
* @param {object} options - Options object
* @param {boolean} options.replace - If true, replace entire context instead of merging
*/
async updateContext(newContext, options = {}) {
if (options.replace) {
// Replace entire context
this.context = { ...newContext };
} else {
// Merge with existing context (default)
this.context = {
...this.context,
...newContext,
};
}
// Clear experiment tracking since context changed
this.experimentTracked.clear();
// Reload flags with new context
await this.loadFlags();
MixpanelLogger.log(this.token, "Context updated, flags reloaded");
}
/**
* Clear cached flags
*/
async clearCache() {
try {
await this.storage.removeItem(this.flagsCacheKey);
await this.storage.removeItem(this.flagsReadyKey);
this.flags = new Map();
this.flagsReady = false;
this.experimentTracked.clear();
} catch (error) {
MixpanelLogger.log(this.token, "Error clearing flag cache:", error);
}
}
// snake_case aliases for API consistency with mixpanel-js
are_flags_ready() {
return this.areFlagsReady();
}
get_variant(featureName, fallback) {
return this.getVariant(featureName, fallback);
}
get_variant_sync(featureName, fallback) {
return this.getVariantSync(featureName, fallback);
}
get_variant_value(featureName, fallbackValue) {
return this.getVariantValue(featureName, fallbackValue);
}
get_variant_value_sync(featureName, fallbackValue) {
return this.getVariantValueSync(featureName, fallbackValue);
}
is_enabled(featureName, fallbackValue = false) {
return this.isEnabled(featureName, fallbackValue);
}
is_enabled_sync(featureName, fallbackValue = false) {
return this.isEnabledSync(featureName, fallbackValue);
}
update_context(newContext, options) {
return this.updateContext(newContext, options);
}
}