better-experiments
Version:
Developer-first A/B testing library
492 lines (487 loc) • 17.9 kB
JavaScript
import { nanoid } from 'nanoid';
/**
* In-memory storage adapter for development and testing
* Data is lost when the process restarts
*/
class MemoryStorage {
constructor() {
this.tests = new Map();
this.assignments = new Map();
this.conversions = [];
}
async saveTest(config) {
this.tests.set(config.testId, { ...config });
}
async getTest(testId) {
return this.tests.get(testId) || null;
}
async getAllTests() {
return Array.from(this.tests.values());
}
async saveAssignment(assignment) {
const key = `${assignment.testId}:${assignment.userId}`;
this.assignments.set(key, { ...assignment });
}
async getAssignment(testId, userId) {
const key = `${testId}:${userId}`;
return this.assignments.get(key) || null;
}
async getAssignmentById(assignmentId) {
for (const assignment of this.assignments.values()) {
if (assignment.id === assignmentId) {
return assignment;
}
}
return null;
}
async saveConversion(event) {
this.conversions.push({ ...event });
}
async getConversions(testId) {
return this.conversions.filter((c) => c.testId === testId);
}
async getTestResults(testId) {
const config = await this.getTest(testId);
if (!config) {
return null;
}
// Get all assignments for this test
const assignments = Array.from(this.assignments.values()).filter((a) => a.testId === testId);
// Get all conversions for this test
const conversions = await this.getConversions(testId);
// Calculate results per variant
const variantResults = config.variants.map((variant) => {
const variantAssignments = assignments.filter((a) => a.variant === variant);
const variantConversions = conversions.filter((c) => c.variant === variant);
// Group conversions by event type
const events = {};
for (const conversion of variantConversions) {
events[conversion.event] = (events[conversion.event] || 0) + 1;
}
const totalUsers = variantAssignments.length;
const totalConversions = variantConversions.length;
return {
variant,
totalUsers,
totalConversions,
conversionRate: totalUsers > 0 ? totalConversions / totalUsers : 0,
events,
};
});
// Calculate basic test statistics
const firstAssignment = assignments[0];
const lastConversion = conversions[conversions.length - 1];
const durationDays = firstAssignment && lastConversion
? Math.ceil((lastConversion.convertedAt.getTime() -
firstAssignment.assignedAt.getTime()) /
(1000 * 60 * 60 * 24))
: 0;
// Simple winner detection (highest conversion rate)
const winner = variantResults.reduce((prev, current) => current.conversionRate > prev.conversionRate ? current : prev);
return {
config,
variants: variantResults,
stats: {
durationDays,
winner: winner.conversionRate > 0 ? winner.variant : undefined,
// TODO: Add proper statistical significance calculation
isSignificant: false,
},
};
}
/**
* Utility methods for debugging/testing
*/
async clear() {
this.tests.clear();
this.assignments.clear();
this.conversions = [];
}
async getStats() {
return {
testsCount: this.tests.size,
assignmentsCount: this.assignments.size,
conversionsCount: this.conversions.length,
};
}
}
/**
* MurmurHash3 32-bit implementation.
* Produces a 32-bit hash value from a string.
*
* @param key The string to hash.
* @param seed An optional seed value.
* @returns A 32-bit unsigned integer hash.
*/
function murmurhash3_32_gc(key, seed = 0) {
let remainder, bytes, h1, h1b, c1, c2, k1, i;
remainder = key.length & 3; // key.length % 4
bytes = key.length - remainder;
h1 = seed;
c1 = 0xcc9e2d51;
c2 = 0x1b873593;
i = 0;
while (i < bytes) {
// Get 4 bytes from the key
k1 =
(key.charCodeAt(i) & 0xff) |
((key.charCodeAt(++i) & 0xff) << 8) |
((key.charCodeAt(++i) & 0xff) << 16) |
((key.charCodeAt(++i) & 0xff) << 24);
++i;
// Bitmagic operations
k1 =
((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 =
((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff;
h1 ^= k1;
h1 = (h1 << 13) | (h1 >>> 19);
h1b =
((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff;
h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16);
}
k1 = 0;
// Handle the remaining bytes
switch (remainder) {
case 3:
k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; // fallthrough
case 2:
k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; // fallthrough
case 1:
k1 ^= key.charCodeAt(i) & 0xff;
k1 =
((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) &
0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 =
((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) &
0xffffffff;
h1 ^= k1;
}
// Finalization mix
h1 ^= key.length;
h1 ^= h1 >>> 16;
h1 =
((h1 & 0xffff) * 0x85ebca6b +
((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) &
0xffffffff;
h1 ^= h1 >>> 13;
h1 =
((h1 & 0xffff) * 0xc2b2ae35 +
((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) &
0xffffffff;
h1 ^= h1 >>> 16;
return h1 >>> 0; // Ensure unsigned 32-bit integer
}
/**
* Create a deterministic numeric hash from a string using MurmurHash3.
* This ensures the same user gets the same variant across sessions.
* The output is an unsigned 32-bit integer.
* @param input The string to hash (e.g., testId + userId).
* @returns An unsigned 32-bit integer hash.
*/
function createUserHash(input) {
const seed = 0; // You can use a fixed seed. Using 0 is common.
return murmurhash3_32_gc(input, seed);
}
/**
* BetterExperiments client - the main interface for A/B testing
*/
class BetterExperiments {
// private dashboardConfig?: BetterExperimentConfig["dashboard"];
constructor(config = {}) {
this.storage = config.storage || new MemoryStorage();
this.debug = config.debug || false;
// this.dashboardConfig = config.dashboard;
this.cookieConfig = {
name: "better-ab-uid",
path: "/",
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
...config.cookie,
};
if (this.debug) {
console.log("[BetterExperiments] Client initialized with config:", {
storage: this.storage.constructor.name,
cookie: this.cookieConfig,
// dashboard: this.dashboardConfig ? "enabled" : "disabled",
});
}
}
/**
* Run an A/B test - returns assignment object with convert method
*/
async test(testId, variants, options) {
const userId = await this.resolveUserId(options?.userId);
if (this.debug) {
console.log(`[BetterExperiments] Running test "${testId}" for user "${userId}"`);
}
// Get or create test configuration
let testConfig = await this.storage.getTest(testId);
if (!testConfig) {
testConfig = await this.createTest({
testId,
variants,
weights: options?.weights,
metadata: {
name: `Auto-generated test: ${testId}`,
...options?.metadata,
},
});
}
// Get user's variant assignment
const assignment = await this.getAssignment(testId, userId);
if (!assignment) {
if (this.debug) {
console.warn(`[BetterExperiments] No variant assigned for test "${testId}", returning first variant`);
}
// Create fallback assignment for first variant
const fallbackAssignment = {
id: nanoid(),
testId,
userId,
variant: variants[0],
assignedAt: new Date(),
};
return {
variant: variants[0],
assignment: fallbackAssignment,
convert: async () => { }, // No-op for fallback
};
}
// Convert variant name back to variant value
const selectedVariant = assignment.variant;
if (this.debug) {
console.log(`[BetterExperiments] User "${userId}" assigned variant "${assignment.variant}" (value: ${JSON.stringify(selectedVariant)})`);
}
// Sync to dashboard if configured
// await this.syncToDashboard("assignment", { assignment, testConfig });
// Return assignment object with convert method
return {
variant: selectedVariant,
assignment,
convert: async (event = "conversion", metadata) => {
await this.trackConversion(assignment, event, metadata);
},
};
}
/**
* Create a new test manually (optional - tests are auto-created)
*/
async createTest(config) {
const fullConfig = {
...config,
active: config.active ?? true,
weights: config.weights || this.generateEqualWeights(config.variants.length),
metadata: {
createdAt: new Date(),
...config.metadata,
},
};
// Validate configuration
this.validateTestConfig(fullConfig);
await this.storage.saveTest(fullConfig);
// Sync to dashboard if configured
// await this.syncToDashboard("test_created", { test: fullConfig });
if (this.debug) {
console.log(`[BetterExperiments] Test "${fullConfig.testId}" created with variants:`, fullConfig.variants);
}
return fullConfig;
}
/**
* Get test results and statistics
*/
async getResults(testId) {
if (this.debug) {
console.log(`[BetterExperiments] Fetching results for test "${testId}"`);
}
return this.storage.getTestResults(testId);
}
/**
* Get all tests
*/
async getTests() {
return this.storage.getAllTests();
}
/**
* Stop/deactivate a test
*/
async stopTest(testId) {
const test = await this.storage.getTest(testId);
if (test) {
test.active = false;
await this.storage.saveTest(test);
// Sync to dashboard if configured
// await this.syncToDashboard("test_stopped", { test });
if (this.debug) {
console.log(`[BetterExperiments] Test "${testId}" stopped`);
}
}
else {
if (this.debug) {
console.warn(`[BetterExperiments] Test "${testId}" not found`);
}
}
return;
}
/**
* Get user's assignment for a specific test
*/
async getAssignment(testId, userId) {
const actualUserId = await this.resolveUserId(userId);
const test = await this.storage.getTest(testId);
if (!test || !test.active) {
return null;
}
// Check if user already has an assignment
const existingAssignment = await this.storage.getAssignment(testId, actualUserId);
if (existingAssignment) {
return existingAssignment;
}
// Assign new variant
const variant = this.assignVariant(testId, actualUserId, test.variants, test.weights);
// Save assignment
const assignment = {
id: nanoid(),
testId,
userId: actualUserId,
variant,
assignedAt: new Date(),
};
await this.storage.saveAssignment(assignment);
return assignment;
}
/**
* Track conversion using assignment (internal method)
*/
async trackConversion(assignment, event = "conversion", metadata) {
if (this.debug) {
console.log(`[BetterExperiments] Tracking event "${event}" for assignment "${assignment.id}"`);
}
const conversionEvent = {
id: nanoid(),
testId: assignment.testId,
userId: assignment.userId,
event,
variant: assignment.variant,
assignmentId: assignment.id,
convertedAt: new Date(),
metadata,
};
await this.storage.saveConversion(conversionEvent);
// Sync to dashboard if configured
// await this.syncToDashboard("conversion", { conversion: conversionEvent });
if (this.debug) {
console.log(`[BetterExperiments] Conversion tracked successfully with ID: ${conversionEvent.id}`);
}
}
/**
* Private methods
*/
async resolveUserId(providedUserId) {
if (providedUserId) {
return providedUserId;
}
return this.defaultGetUserId();
}
defaultGetUserId() {
// Try to get from cookie first (browser environment)
if (typeof document !== "undefined") {
const cookieUserId = this.getCookieUserId();
if (cookieUserId) {
return cookieUserId;
}
}
// Generate new user ID
const userId = nanoid();
// Set cookie if in browser
if (typeof document !== "undefined") {
this.setCookieUserId(userId);
}
return userId;
}
getCookieUserId() {
if (typeof document === "undefined")
return null;
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === this.cookieConfig.name) {
return decodeURIComponent(value);
}
}
return null;
}
setCookieUserId(userId) {
if (typeof document === "undefined")
return;
const cookieParts = [
`${this.cookieConfig.name}=${encodeURIComponent(userId)}`,
];
if (this.cookieConfig.path) {
cookieParts.push(`path=${this.cookieConfig.path}`);
}
if (this.cookieConfig.domain) {
cookieParts.push(`domain=${this.cookieConfig.domain}`);
}
if (this.cookieConfig.maxAge) {
const expires = new Date(Date.now() + this.cookieConfig.maxAge);
cookieParts.push(`expires=${expires.toUTCString()}`);
}
if (this.cookieConfig.secure) {
cookieParts.push("secure");
}
if (this.cookieConfig.sameSite) {
cookieParts.push(`samesite=${this.cookieConfig.sameSite}`);
}
document.cookie = cookieParts.join("; ");
}
assignVariant(testId, userId, variants, weights) {
// Ensure variants and weights are not empty and have the same length
if (variants.length === 0 || variants.length !== weights.length) {
// Throw an error, or return a default/control variant
console.error("Variants and weights mismatch or empty.");
throw new Error("Variants and weights must be non-empty and have the same length.");
}
// Create deterministic numeric hash from testId + userId
const numericHash = createUserHash(testId + userId);
// Convert to number between 0 and 1 (inclusive of 0, potentially inclusive of 1)
// 0xffffffff is 2^32 - 1, the maximum value for an unsigned 32-bit integer.
const hashNumber = numericHash / 0xffffffff;
// Use weights to determine variant
let cumulativeWeight = 0;
for (let i = 0; i < variants.length; i++) {
cumulativeWeight += weights[i]; // Assumes weights[i] is defined
if (hashNumber <= cumulativeWeight) {
return variants[i]; // Assumes variants[i] is defined
}
}
// Fallback to last variant.
// This should ideally be hit only in edge cases like floating point inaccuracies
// or if hashNumber is exactly 1.0 and the last variant's cumulative weight is 1.0.
return variants[variants.length - 1];
}
generateEqualWeights(variantCount) {
const weight = 1 / variantCount;
return Array(variantCount).fill(weight);
}
validateTestConfig(config) {
if (!config.testId) {
throw new Error("Test ID is required");
}
if (!config.variants || config.variants.length < 2) {
throw new Error("At least 2 variants are required");
}
if (config.weights && config.weights.length !== config.variants.length) {
throw new Error("Weights array must match variants array length");
}
if (config.weights) {
const sum = config.weights.reduce((a, b) => a + b, 0);
if (Math.abs(sum - 1) > 0.001) {
throw new Error("Weights must sum to 1");
}
}
}
}
export { BetterExperiments, MemoryStorage, createUserHash };
//# sourceMappingURL=index.esm.js.map