Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ jobs:
id: bump_version
run: |
current_version=${{ steps.get_version.outputs.current_version }}
# A version set in package.json that is newer than the last release
# wins, so breaking changes can ship as a minor or major release.
package_version=$(node -p "require('./package.json').version")
newest=$(printf '%s\n%s\n' "$current_version" "$package_version" | sort -V | tail -1)
if [ "$package_version" != "$current_version" ] && [ "$newest" = "$package_version" ]; then
echo "new_version=$package_version" >> $GITHUB_OUTPUT
exit 0
fi
major=$(echo $current_version | cut -d '.' -f 1)
minor=$(echo $current_version | cut -d '.' -f 2)
patch=$(echo $current_version | cut -d '.' -f 3)
Expand Down
105 changes: 58 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AB Testify

ABTestify is a versatile and user-friendly npm package for implementing A/B testing seamlessly into your JavaScript projects. Whether you're a developer looking to optimize user experiences or a product manager seeking data-driven insights, ABTestify empowers you to effortlessly create, manage, and analyze A/B tests.
Deterministic A/B test bucketing for JavaScript. Give it an experiment name and a user ID and it returns the same variant for that user every time, with no storage and no network calls. It runs anywhere JavaScript does: Node, browsers, React and Next.js (server and client), Remix, and edge runtimes.

## Test Coverage

Expand All @@ -12,81 +12,92 @@ ABTestify is a versatile and user-friendly npm package for implementing A/B test

## Installation

You can install the package via npm:

```bash
npm install abtestify
```

Or via yarn:
Or with yarn or pnpm:

```bash
yarn add abtestify
pnpm add abtestify
```

### Function Parameters
The function takes an object with the following properties:

* `experimentName` (required): The name of the experiment.
* `uniqueId` (required): The unique identifier for the user. This can be an email, user ID, etc.
* `treatmentSplit` (optional, default: 50): The percentage of users to be bucketed into the treatment group.
* `exposure` (optional, default: 100): The percentage of users to be bucketed into the experiment.
* `controlVariantName` (optional, default: 'control'): The name of the control variant.
* `treatmentVariantName` (optional, default: 'treatment'): The name of the treatment variant.
* `ignoreVariantName` (optional, default: 'ignore'): The name of the variant to be ignored.

### Function Output
The function returns an object with the following properties:

* `experimentName`: The name of the experiment.
* `uniqueId`: The unique identifier for the user.
* `resolvedVariant`: The variant that the user is assigned to, or null if there was an error.
* `isError` (optional): Indicates if there was an error while assigning the variant.
* `error` (optional): The error object, if isError is true.

## Usage

### React/Next.js Projects

```javascript
// Import the function
import { getABExperimentBucket } from "abtestify";
import { getABExperimentBucket } from 'abtestify';
// or: const { getABExperimentBucket } = require('abtestify');

// Use it in your component
const variant = getABExperimentBucket({
experimentName: "exampleExperiment",
uniqueId: "user123",
const { resolvedVariant } = getABExperimentBucket({
experimentName: 'checkout-cta',
uniqueId: 'user123',
treatmentSplit: 50,
exposure: 100,
});

console.log("Variant:", variant);
if (resolvedVariant === 'treatment') {
// show the new checkout button
}
```

### Normal JavaScript Projects
The package ships ES modules and CommonJS, with TypeScript types for both.

```javascript
// Import the function
const { getABExperimentBucket } = require("abtestify");
### Parameters

// Use it in your code
const variant = getABExperimentBucket({
experimentName: "exampleExperiment",
uniqueId: "user123",
treatmentSplit: 50,
exposure: 100,
});
- `experimentName` (required): the name of the experiment. It is part of the hash, so each experiment splits users independently.
- `uniqueId` (required): a stable identifier for the user, such as a user ID or a device ID.
- `treatmentSplit` (optional, default `50`): the percentage of users in the experiment who get the treatment.
- `exposure` (optional, default `100`): the percentage of all users who are in the experiment. Everyone else gets the ignore variant.
- `controlVariantName` (optional, default `'control'`): the name of the control variant.
- `treatmentVariantName` (optional, default `'treatment'`): the name of the treatment variant.
- `ignoreVariantName` (optional, default `'ignore'`): the name returned for users outside the experiment.
- `legacyHashing` (optional, default `false`): bucket by `uniqueId` alone, as 0.1.x did. See [Upgrading from 0.1.x](#upgrading-from-01x).

Invalid input throws an `Error`: an empty or non-string `uniqueId` or `experimentName`, or a `treatmentSplit` or `exposure` that isn't a number from 0 to 100.

### Output

console.log("Variant:", variant);
- `experimentName`: the experiment name you passed.
- `uniqueId`: the user ID you passed.
- `resolvedVariant`: the variant the user is in: the treatment, control or ignore name.

### How bucketing works

The user's bucket is the first 8 bytes of SHA-256 of `experimentName:uniqueId`, mapped to one of 10,000 buckets. Exposure takes the lowest buckets, and the split uses the bucket's last two digits, which means:

- The same user gets the same variant in an experiment on every call, on every platform.
- Different experiments split users independently.
- Raising `exposure` (say from 10 to 50) only adds users. Nobody already in the experiment changes variant.
- Raising `treatmentSplit` only moves users from control to treatment.

## Upgrading from 0.1.x

0.1.x hashed only `uniqueId` and ignored `experimentName`. A user therefore got the same variant in every experiment: someone in treatment for one experiment was in treatment for all of them, so experiments running at the same time skewed each other's results.

0.2.0 includes `experimentName` in the hash. **This reassigns users.** To keep the assignments of experiments that are already running, pass `legacyHashing: true` to them until they finish; it reproduces 0.1.x exactly. Start new experiments without it.

```javascript
getABExperimentBucket({
experimentName: 'running-since-0.1',
uniqueId: 'user123',
legacyHashing: true,
});
```

Other changes in 0.2.0:

- Works in browsers and edge runtimes. 0.1.x imported Node's `crypto` and `Buffer`.
- Invalid input throws instead of returning `resolvedVariant: null` with `isError`.
- Ships CommonJS as well as ES modules.

[npm-coverage-image]: https://codecov.io/gh/pranav2012/abtestify/graph/badge.svg?token=CHCUS36FCG
[npm-coverage-url]: https://codecov.io/gh/pranav2012/abtestify
[npm-install-size-image]:https://packagephobia.com/badge?p=abtestify
[npm-install-size-image]: https://packagephobia.com/badge?p=abtestify
[npm-install-size-url]: https://packagephobia.com/result?p=abtestify
[npm-downloads-image]: https://badgen.net/npm/dm/abtestify
[npm-downloads-url]: https://npmcharts.com/compare/abtestify?minimal=true
[npm-version-image]: https://badgen.net/npm/v/abtestify
[npm-url]: https://npmjs.org/package/abtestify
[npm-mit-image]: https://badges.frapsoft.com/os/mit/mit.svg?v=103
[npm-mit-url]: https://opensource.org/licenses/mit-license.php
[npm-mit-url]: https://opensource.org/licenses/mit-license.php
26 changes: 21 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
{
"name": "abtestify",
"version": "0.0.1",
"version": "0.2.0",
"type": "module",
"description": "abtestify is a package to do ab testing in your project in next.js react or any javascript frameworks",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"description": "Deterministic A/B test bucketing for Node, browsers and edge runtimes, with per-experiment hashing, exposure and split control",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist"
],
"repository": "https://github.com/pranav2012/abtestify.git",
"author": "pranav2012 <p2012agarwal@gmail.com>",
"license": "MIT",
Expand Down
17 changes: 17 additions & 0 deletions src/bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { sha256 } from './sha256';

export const BUCKETS = 10000;

/**
* Maps a key to one of 10,000 buckets from the first 8 bytes of its SHA-256.
* The arithmetic, including the float rounding, matches 0.1.x exactly, so
* `legacyHashing` reproduces earlier assignments.
*/
export const bucketOf = (key: string): number => {
const digest = sha256(key);
let top = BigInt(0);
for (let i = 0; i < 8; i++) top = (top << BigInt(8)) | BigInt(digest[i]);
return Math.floor(
(Number(top) / Number(BigInt('0xFFFFFFFFFFFFFFFF'))) * BUCKETS,
);
};
91 changes: 45 additions & 46 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import * as crypto from 'crypto';
import { IExperimentData, IParams } from '@types';
import { bucketOf } from './bucket';
import type { IExperimentData, IParams } from './types/interface';

export type { IExperimentData, IParams } from './types/interface';

const assertPercentage = (name: string, value: unknown, meaning: string) => {
if (
typeof value !== 'number' ||
!Number.isFinite(value) ||
value < 0 ||
value > 100
) {
throw new Error(
`${name} must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into ${meaning}.`,
);
}
};

const assertText = (name: string, value: unknown) => {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${name} must be a non-empty string.`);
}
};

/**
* Deterministically assigns a user to a variant of an experiment.
*
* The bucket comes from SHA-256 of `experimentName:uniqueId`, so the same
* user always gets the same variant within an experiment, and independent
* variants across experiments. Raising `exposure` only adds users; it never
* moves anyone between variants.
*/
export const getABExperimentBucket = ({
experimentName,
uniqueId,
Expand All @@ -9,52 +38,22 @@ export const getABExperimentBucket = ({
treatmentVariantName = 'treatment',
controlVariantName = 'control',
ignoreVariantName = 'ignore',
legacyHashing = false,
}: IParams): IExperimentData => {
if (treatmentSplit < 0 || treatmentSplit > 100) {
throw new Error(
'treatmentSplit must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the treatment group.',
);
}
if (exposure < 0 || exposure > 100) {
throw new Error(
'exposure must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the experiment.',
);
}
assertPercentage('treatmentSplit', treatmentSplit, 'the treatment group');
assertPercentage('exposure', exposure, 'the experiment');
assertText('uniqueId', uniqueId);
if (!legacyHashing) assertText('experimentName', experimentName);

try {
const digest = crypto.createHash('sha256');
const hashBytes = digest.update(uniqueId).digest(); // Calculate the hash of the ID
const hashedId = BigInt(
`0x${Buffer.from(hashBytes).subarray(0, 8).toString('hex')}`,
); // Convert the hashed ID to a 64-bit BigInt using subarray instead of slice
const normalizedHashedId =
Number(hashedId) / Number(BigInt('0xFFFFFFFFFFFFFFFF')); // Convert the hashed ID to a float between 0 and 1
const randomNum = Math.floor(normalizedHashedId * 10000); // Multiply the normalized hashed ID by 10,000 to get a random number between 0 and 9,999
const bucket = bucketOf(
legacyHashing ? uniqueId : `${experimentName}:${uniqueId}`,
);

if (randomNum / 100 >= exposure)
return {
experimentName,
uniqueId,
resolvedVariant: ignoreVariantName,
}; // Check if user should be ignored based on exposure rate
let resolvedVariant: string;
if (bucket / 100 >= exposure) resolvedVariant = ignoreVariantName;
else if (bucket % 100 < treatmentSplit)
resolvedVariant = treatmentVariantName;
else resolvedVariant = controlVariantName;

const resolvedVariant =
randomNum % 100 < treatmentSplit
? treatmentVariantName
: controlVariantName;

return {
experimentName,
uniqueId,
resolvedVariant,
};
} catch (error) {
return {
experimentName,
uniqueId,
resolvedVariant: null,
isError: true,
error,
};
}
return { experimentName, uniqueId, resolvedVariant };
};
76 changes: 76 additions & 0 deletions src/sha256.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);

const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));

/**
* SHA-256 of a string's UTF-8 bytes (FIPS 180-4), the same digest as
* Node's `createHash('sha256').update(text)`. Synchronous and dependency-free,
* so bucketing works in browsers and edge runtimes as well as Node.
*/
export const sha256 = (text: string): Uint8Array => {
const data = new TextEncoder().encode(text);
// Message, a 0x80 byte, zero padding, then the bit length as 64 bits.
const padded = new Uint8Array(((data.length + 72) >> 6) << 6);
padded.set(data);
padded[data.length] = 0x80;
const view = new DataView(padded.buffer);
const bits = data.length * 8;
view.setUint32(padded.length - 8, Math.floor(bits / 2 ** 32));
view.setUint32(padded.length - 4, bits >>> 0);

const hash = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
0x1f83d9ab, 0x5be0cd19,
]);
const w = new Uint32Array(64);
for (let offset = 0; offset < padded.length; offset += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
for (let i = 16; i < 64; i++) {
const s0 =
rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 =
rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
let [a, b, c, d, e, f, g, h] = hash;
for (let i = 0; i < 64; i++) {
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const t1 = (h + s1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) | 0;
h = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
hash[0] += a;
hash[1] += b;
hash[2] += c;
hash[3] += d;
hash[4] += e;
hash[5] += f;
hash[6] += g;
hash[7] += h;
}

const digest = new Uint8Array(32);
const out = new DataView(digest.buffer);
hash.forEach((word, i) => out.setUint32(i * 4, word));
return digest;
};
Loading
Loading