UNPKG

better-experiments

Version:
492 lines (487 loc) 17.9 kB
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