From 06fdf29a88b8678c8c4a1ce700f51f1874a264dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ekeberg?= Date: Tue, 28 Apr 2020 23:04:50 +0200 Subject: [PATCH] Initial release --- .gitignore | 7 + CHANGELOG.md | 9 + LICENSE | 21 +++ README.md | 198 ++++++++++++++++++++ composer.json | 50 +++++ docs/Experiment.md | 305 ++++++++++++++++++++++++++++++ docs/Group.md | 450 +++++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 9 + docs/Result.md | 268 +++++++++++++++++++++++++++ docs/Token.md | 166 +++++++++++++++++ docs/User.md | 277 ++++++++++++++++++++++++++++ src/Experiment.php | 279 ++++++++++++++++++++++++++++ src/Group.php | 268 +++++++++++++++++++++++++++ src/Result.php | 417 +++++++++++++++++++++++++++++++++++++++++ src/Token.php | 155 ++++++++++++++++ src/User.php | 294 +++++++++++++++++++++++++++++ 16 files changed, 3173 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 docs/Experiment.md create mode 100644 docs/Group.md create mode 100644 docs/README.md create mode 100644 docs/Result.md create mode 100644 docs/Token.md create mode 100644 docs/User.md create mode 100644 src/Experiment.php create mode 100644 src/Group.php create mode 100644 src/Result.php create mode 100644 src/Token.php create mode 100644 src/User.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9f3464 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# OS files +.DS_Store +Thumbs.db + +# Composer +vendor +composer.lock \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77ef30f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2020-04-29 + +Initial release + +[1.0.0]: https://github.com/andreekeberg/abby/releases/tag/1.0.0 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f49a05f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 André Ekeberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b0d934 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# 🙋‍♀️ Abby + +Abby is a simple but powerful A/B testing library. + +The library lets you easily setup your **experiments** and their **control** and **variation** groups, **track** your visitors and assign them to a group, get detailed statistics including recommended **sample sizes** and determining the confidence of your results, including when an experiment have achieved **statistical significance**. + +The confidence is calculated using the [z-score](https://en.wikipedia.org/wiki/Standard_score) and [p-value](https://en.wikipedia.org/wiki/P-value) of your results, to see if the [null hypothesis](http://en.wikipedia.org/wiki/Null_hypothesis) can be rejected. An accompanying minimum [sample size](https://en.wikipedia.org/wiki/Sample_size_determination) is also calculated using a [two-tailed test](https://en.wikipedia.org/wiki/One-_and_two-tailed_tests) to control the [false discovery rate](https://en.wikipedia.org/wiki/False_discovery_rate). + +Abby is dependency free, and completely database agnostic, meaning it simply works with data you provide it with, and exposes a variety of methods for you to store the result in your own storage of choice. + +## Requirements + +- PHP 5.4.0 or higher + +## Installation + +``` +composer require andreekeberg/abby +``` + +## Basic usage + +### Tracking a user + +```php +// Setup a new Token instance +$token = new Abby\Token(); + +// If we can't find an existing token cookie, generate one and set tracking cookie +if (!$token->getValue()) { + $token->generate()->setCookie(); +} + +// Setup a User instance +$user = new Abby\User(); + +// Associate the token with our user +$user->setToken($token); +``` + +### Adding existing user experiments to a user instance + +```php +// List of experiments associated with a tracking token +$data = [ + [ + 'id' => 1, + 'group' => 1, + 'converted' => false + ] +]; + +// Loop through users existing experiments and add them to our user instance +foreach ($data as $item) { + // Setup experiment instance based on an existing experiment + $experiment = new Abby\Experiment([ + 'id' => $item['id'] + ]); + + // Setup a group instance based on stored data + $group = new Abby\Group([ + 'type' => $item['group'] + ]); + + // Add the experiment (including their group and whether they have + // already converted) to our user instance + $user->addExperiment($experiment, $group, $item['converted']); +} +``` + +### Including the using in new experiments +```php +// Experiment data +$data = [ + 'id' => 2 +]; + +// Make sure the experiment isn't already in the users list +if (!$user->hasExperiment($data['id'])) { + // Setup a new experiment instance + $experiment = new Abby\Experiment([ + 'id' => $data['id'] + ]); + + // Assign the user to either control or variation in the experiment + $group = $user->assignGroup($experiment); + + // Add the experiment (including assigned group) to our user instance + $user->addExperiment($experiment, $group); +} + +// Getting updated user experiment list +$user->getExperiments(); + +// Store updated experiment list for our user +``` + +### Delivering a custom experience based on group participation + +```php +// Experiment data +$data = [ + 'id' => 1 +]; + +// If the user is part of the variation in our experiment +if ($user->inVariation($data['id'])) { + // Apply a custom class to an element, load a script, etc. +} +``` + +### Defining a user conversion in an experiment + +```php +// Experiment data +$data = [ + 'id' => 1 +]; + +// On a custom experiment goal, check if user is a participant and define a conversion +if ($user->isParticipant($data['id'])) { + $user->setConverted($data['id']); +} + +// Getting updated user experiment data +$user->getExperiments(); + +// Store updated data for our user +``` + +### Getting experiment results + +```php +// Setup experiment instance with stored results +$experiment = new Abby\Experiment([ + 'groups' => [ + [ + 'name' => 'Control', + 'size' => 3000, + 'conversions' => 300 + ], + [ + 'name' => 'Variation', + 'size' => 3000, + 'conversions' => 364 + ] + ] +]); + +// Retrieve the results +$result = $experiment->getResult(); + +// Get the winner +$winner = $result->getWinner(); + +/** + * Get whether we can be confident of the result (even if we haven't + * reached the minimum group size for each variant) + */ + +$confident = $result->isConfident(); + +/** + * Get the minimum sample size required for each group to reach statistical + * significance, given the control groups current conversion rate (based on + * the configured minimumDetectableEffect) + */ + +$minimum = $result->getMinimumGroupSize(); + +/** + * Get whether the results are statistically significant + */ + +$significant = $result->isSignificant(); + +/** + * Get complete experiment result + */ + +$summary = $result->getAll(); +``` + +## Documentation + +* [Experiment](docs/Experiment.md) +* [Token](docs/Token.md) +* [User](docs/User.md) +* [Group](docs/Group.md) +* [Result](docs/Result.md) + +## Changelog + +Refer to the [changelog](CHANGELOG.md) for a full history of the project. + +## License + +Abby is licensed under the [MIT license](LICENSE). \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9b2b5f7 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "andreekeberg/abby", + "type": "library", + "description": "🙋‍♀️ Minimal A/B Testing Library", + "keywords": [ + "ab test", + "ab testing", + "conficence", + "conversion rate", + "conversion ratio", + "conversions", + "false discovery rate", + "p-value", + "probability value", + "probability", + "ratio", + "sample size", + "sample", + "sequential testing", + "significance", + "split test", + "split testing", + "split-run", + "standard score", + "statistics", + "testing", + "two-sample", + "two-tailed", + "two-tailed test", + "z-score" + ], + "homepage": "https://github.com/andreekeberg/abby", + "license": "MIT", + "authors": [ + { + "name": "André Ekeberg", + "email": "andre@pocketsize.se", + "homepage": "https://github.com/andreekeberg", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Abby\\": "src/" + } + }, + "require": { + "php": ">=5.4.0" + } +} diff --git a/docs/Experiment.md b/docs/Experiment.md new file mode 100644 index 0000000..46538a6 --- /dev/null +++ b/docs/Experiment.md @@ -0,0 +1,305 @@ +# Experiment + +This class is in charge of managing your experiment, including configuring the name, identifier, optionally limiting the percentual amount of visitors to include, defining and handling their control and variant groups, and returning your results. + +| Name | Description | +|------|-------------| +|[getID](#experimentgetid)|Get experiment ID| +|[setID](#experimentsetid)|Set experiment ID| +|[getName](#experimentgetname)|Get experiment name| +|[setName](#experimentsetname)|Set experiment name| +|[getGroups](#experimentgetgroups)|Get both groups| +|[getGroup](#experimentgetgroup)|Get a specific group| +|[setGroup](#experimentsetgroup)|Define a group| +|[getControl](#experimentgetcontrol)|Get the control| +|[setControl](#experimentsetcontrol)|Define the control| +|[getVariation](#experimentgetvariation)|Get the variation| +|[setVariation](#experimentsetvariation)|Define the variation| +|[getCoverage](#experimentgetcoverage)|Get experiment coverage| +|[setCoverage](#experimentsetcoverage)|Set experiment coverage| +|[getResult](#experimentgetresult)|Return a Result instance from the current experiment| + +## Experiment::getID + +**Description** + +```php +public getID (void) +``` + +Get experiment ID + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`mixed|null` + +
+ +## Experiment::setID + +**Description** + +```php +public setID (mixed $id) +``` + +Set experiment ID + +**Parameters** + +* `(mixed) $id` + +**Return Values** + +`self` + +
+ +## Experiment::getName + +**Description** + +```php +public getName (void) +``` + +Get experiment name + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`string|null` + +
+ +## Experiment::setName + +**Description** + +```php +public setName (string $name) +``` + +Set experiment name + +**Parameters** + +* `(string) $name` + +**Return Values** + +`self` + +
+ +## Experiment::getGroups + +**Description** + +```php +public getGroups (void) +``` + +Get both groups + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`array` + +
+ +## Experiment::getGroup + +**Description** + +```php +public getGroup (int|string $key) +``` + +Get a specific group + +**Parameters** + +* `(int|string) $key` + +**Return Values** + +`Group` + +
+ +## Experiment::setGroup + +**Description** + +```php +public setGroup (int|string $key, Group|array|object $group) +``` + +Define a group + +**Parameters** + +* `(int|string) $key` +* `(Group|array|object) $group` + +**Return Values** + +`self` + +
+ +## Experiment::getControl + +**Description** + +```php +public getControl (void) +``` + +Get the control + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Group` + +
+ +## Experiment::setControl + +**Description** + +```php +public setControl (Group|array|object $group) +``` + +Define the control + +**Parameters** + +* `(Group|array|object) $group` + +**Return Values** + +`self` + +
+ +## Experiment::getVariation + +**Description** + +```php +public getVariation (void) +``` + +Get the variation + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Group` + +
+ +## Experiment::setVariation + +**Description** + +```php +public setVariation (Group|array|object $group) +``` + +Define the variation + +**Parameters** + +* `(Group|array|object) $group` + +**Return Values** + +`self` + +
+ +## Experiment::getCoverage + +**Description** + +```php +public getCoverage (void) +``` + +Get experiment coverage + +This is the percentual chance that a new user will be included in the experiment + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`int` + +
+ +## Experiment::setCoverage + +**Description** + +```php +public setCoverage (int $percent) +``` + +Set experiment coverage + +This is the percentual chance that a new user will be included in the experiment + +**Parameters** + +* `(int) $percent` + +**Return Values** + +`self` + +
+ +## Experiment::getResult + +**Description** + +```php +public getResult (void) +``` + +Return a Result instance from the current experiment + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Result` + +
\ No newline at end of file diff --git a/docs/Group.md b/docs/Group.md new file mode 100644 index 0000000..3831c35 --- /dev/null +++ b/docs/Group.md @@ -0,0 +1,450 @@ +# Group + +This class is in charge of creating and handling the control and variation groups of an experiment, including getting and setting their values such as group type, size, number of conversions, and whether the group is the winning or losing variant. + +| Name | Description | +|------|-------------| +|[getValues](#groupgetvalues)|Get all values for the group| +|[getValue](#groupgetvalue)|Get property value of group| +|[setValue](#groupsetvalue)|Set property value of group| +|[getName](#groupgetname)|Get group name| +|[setName](#groupsetname)|Set group name| +|[getSize](#groupgetsize)|Get group size| +|[setSize](#groupsetsize)|Set group size| +|[getConversions](#groupgetconversions)|Get number of conversions for the group| +|[setConversions](#groupsetconversions)|Set number of conversions for the group| +|[getConversionRate](#groupgetconversionrate)|Get conversion rate for the group| +|[isWinner](#groupiswinner)|Get whether the group is the winner| +|[setWinner](#groupsetwinner)|Define the group as the winner| +|[isLoser](#groupisloser)|Get whether the group is the loser| +|[setLoser](#groupsetloser)|Define the group as the loser| +|[getType](#groupgettype)|Get group type| +|[isType](#groupistype)|Get whether the variation is a specific type| +|[setType](#groupsettype)|Set group type| +|[isControl](#groupiscontrol)|Get whether the group is the control| +|[setControl](#groupsetcontrol)|Define the group as the control| +|[isVariation](#groupisvariation)|Get whether the group is the variation| +|[setVariation](#groupsetvariation)|Define the group as the variation| + +## Group::getValues + +**Description** + +```php +public getValues (void) +``` + +Get all values for the group + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`object` + +
+ +## Group::getValue + +**Description** + +```php +public getValue (int|string $property) +``` + +Get property value of group + +**Parameters** + +* `(int|string) $property` + +**Return Values** + +`mixed` + +
+ +## Group::setValue + +**Description** + +```php +public setValue (string $property, mixed $value) +``` + +Set property value of group + +**Parameters** + +* `(string) $property` +* `(mixed) $value` + +**Return Values** + +`self` + +
+ +## Group::getName + +**Description** + +```php +public getName (void) +``` + +Get group name + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`null|string` + +
+ +## Group::setName + +**Description** + +```php +public setName (string $name) +``` + +Set group name + +**Parameters** + +* `(string) $name` + +**Return Values** + +`self` + +
+ +## Group::getSize + +**Description** + +```php +public getSize (void) +``` + +Get group size + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`int` + +
+ +## Group::setSize + +**Description** + +```php +public setSize (int $size) +``` + +Set group size + +**Parameters** + +* `(int) $size` + +**Return Values** + +`self` + +
+ +## Group::getConversions + +**Description** + +```php +public getConversions (void) +``` + +Get number of conversions for the group + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`int` + +
+ +## Group::setConversions + +**Description** + +```php +public setConversions (int $conversions) +``` + +Set number of conversions for the group + +**Parameters** + +* `(int) $conversions` + +**Return Values** + +`self` + +
+ +## Group::getConversionRate + +**Description** + +```php +public getConversionRate (void) +``` + +Get conversion rate for the group + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`float` + +
+ +## Group::isWinner + +**Description** + +```php +public isWinner (void) +``` + +Get whether the group is the winner + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Group::setWinner + +**Description** + +```php +public setWinner (void) +``` + +Define the group as the winner + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`self` + +
+ +## Group::isLoser + +**Description** + +```php +public isLoser (void) +``` + +Get whether the group is the loser + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Group::setLoser + +**Description** + +```php +public setLoser (void) +``` + +Define the group as the loser + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`self` + +
+ +## Group::getType + +**Description** + +```php +public getType (void) +``` + +Get group type + +Returns 0 for control, 1 for variation and null when type is unknown + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`int|null` + +
+ +## Group::isType + +**Description** + +```php +public isType (int $type) +``` + +Get whether the variation is a specific type + +**Parameters** + +* `(int) $type` + +**Return Values** + +`bool` + +
+ +## Group::setType + +**Description** + +```php +public setType (int $type) +``` + +Set group type + +**Parameters** + +* `(int) $type` + +**Return Values** + +`self` + +
+ +## Group::isControl + +**Description** + +```php +public isControl (void) +``` + +Get whether the group is the control + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Group::setControl + +**Description** + +```php +public setControl (void) +``` + +Define the group as the control + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`self` + +
+ +## Group::isVariation + +**Description** + +```php +public isVariation (void) +``` + +Get whether the group is the variation + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Group::setVariation + +**Description** + +```php +public setVariation (void) +``` + +Define the group as the variation + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`self` + +
\ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8f61829 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Documentation + +Full documentation of all the available classes and methods + +* [Experiment](Experiment.md) +* [Token](Token.md) +* [User](User.md) +* [Group](Group.md) +* [Result](Result.md) \ No newline at end of file diff --git a/docs/Result.md b/docs/Result.md new file mode 100644 index 0000000..ef880a6 --- /dev/null +++ b/docs/Result.md @@ -0,0 +1,268 @@ +# Result + +This class is in charge of calculating your experiment winner, relative conversion rate change between groups, the level of confidence of the results (including whether you have achieved statistical significance), and getting a minimum sample size for your variants. + +| Name | Description | +|------|-------------| +|[getMinimumConfidence](#resultgetminimumconfidence)|Get the specified minimum confidence| +|[setMinimumConfidence](#resultsetminimumconfidence)|Set the minimum confidence| +|[getMinimumDetectableEffect](#resultgetminimumdetectableeffect)|Get the specified minimum detectable effect| +|[setMinimumDetectableEffect](#resultsetminimumdetectableeffect)|Set the minimum detectable effect| +|[getWinner](#resultgetwinner)|Get the winning group| +|[getLoser](#resultgetloser)|Get the losing group| +|[getConversionRateChange](#resultgetconversionratechange)|Get the percentual conversion rate change between the control and variation| +|[getConfidence](#resultgetconfidence)|Get confidence of result| +|[isConfident](#resultisconfident)|Return whether we can be confident of the result| +|[getMinimumSampleSize](#resultgetminimumsamplesize)|Get minimum sample size for the current experiment| +|[isSignificant](#resultissignificant)|Return whether the result is statistically significant| +|[getAll](#resultgetall)|Get complete results| + +## Result::getMinimumConfidence + +**Description** + +```php +public getMinimumConfidence (void) +``` + +Get the specified minimum confidence + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`float` + +
+ +## Result::setMinimumConfidence + +**Description** + +```php +public setMinimumConfidence (float $confidence) +``` + +Set the minimum confidence + +**Parameters** + +* `(float) $confidence` + +**Return Values** + +`self` + +
+ +## Result::getMinimumDetectableEffect + +**Description** + +```php +public getMinimumDetectableEffect (void) +``` + +Get the specified minimum detectable effect + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`float` + +
+ +## Result::setMinimumDetectableEffect + +**Description** + +```php +public setMinimumDetectableEffect (float $effect) +``` + +Set the minimum detectable effect + +**Parameters** + +* `(float) $effect` + +**Return Values** + +`self` + +
+ +## Result::getWinner + +**Description** + +```php +public getWinner (void) +``` + +Get the winning group + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Group|null` + +
+ +## Result::getLoser + +**Description** + +```php +public getLoser (void) +``` + +Get the losing group + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Group|null` + +
+ +## Result::getConversionRateChange + +**Description** + +```php +public getConversionRateChange (void) +``` + +Get the percentual conversion rate change between the control and variation + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`float` + +
+ +## Result::getConfidence + +**Description** + +```php +public getConfidence (void) +``` + +Get confidence of result + +Returns the probability that the null hypothesis (the hypothesis that there is no difference or no change between the two variants) can be rejected + +This is called the alternative hypothesis (inverse of the probability value) + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`float` + +
+ +## Result::isConfident + +**Description** + +```php +public isConfident (void) +``` + +Return whether we can be confident of the result + +This requires the confidence to be greater than or equal to the configured minimum confidence + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Result::getMinimumSampleSize + +**Description** + +```php +public getMinimumSampleSize (void) +``` + +Get minimum sample size for the current experiment + +This is based on the control conversion rate, minimum confidence, and minimum detectable change in conversion rate, and is calculated using a two-tailed test with a false discovery rate control + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`int` + +
+ +## Result::isSignificant + +**Description** + +```php +public isSignificant (void) +``` + +Return whether the result is statistically significant + +This requires both the confidence (alternative hypothesis, i.e the inverse of the p-value) to be high enough (based on the specified minimum confidence) that the null hypothesis can be rejected, and the size of both groups to be greater than or equal to the minimum sample size (which is in turn calculated based on the minimum detectable effect, and the conversion rate of the control group) + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`bool` + +
+ +## Result::getAll + +**Description** + +```php +public getAll (void) +``` + +Get complete results + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`object` + +
\ No newline at end of file diff --git a/docs/Token.md b/docs/Token.md new file mode 100644 index 0000000..da9126b --- /dev/null +++ b/docs/Token.md @@ -0,0 +1,166 @@ +# Token + +This class is in charge of managing and generating unique tokens to associate with your visitors, including methods for setting and removing their tracking cookies. + +| Name | Description | +|------|-------------| +|[getName](#tokengetname)|Get tracking token name| +|[setName](#tokensetname)|Set tracking token name| +|[getValue](#tokengetvalue)|Get tracking token value| +|[setValue](#tokensetvalue)|Set tracking token value| +|[generate](#tokengenerate)|Set the token value to a new generated hash| +|[setCookie](#tokensetcookie)|Set tracking cookie with the token name and value| +|[removeCookie](#tokenremovecookie)|Remove tracking cookie| + +## Token::getName + +**Description** + +```php +public getName (void) +``` + +Get tracking token name + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`string|null` + +
+ +## Token::setName + +**Description** + +```php +public setName (string $name) +``` + +Set tracking token name + +**Parameters** + +* `(string) $name` + +**Return Values** + +`self` + +
+ +## Token::getValue + +**Description** + +```php +public getValue (void) +``` + +Get tracking token value + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`string|null` + +
+ +## Token::generate + +**Description** + +```php +public generate (mixed $id) +``` + +Set the token value to a new generated hash + +Use `$id` to generate a static hash that will always be the same given the current token name and identifier +This is useful for creating a token based on e.g. a user ID + +If no identifier is passed, a unique hash is randomly generated + +**Parameters** + +* `(mixed) $id` +: Unique identifier (e.g. a user ID) + +**Return Values** + +`self` + +
+ +## Token::setValue + +**Description** + +```php +public setValue (string $value) +``` + +Set tracking token value + +**Parameters** + +* `(string) $value` + +**Return Values** + +`self` + +
+ +## Token::setCookie + +**Description** + +```php +public setCookie (string|null $value, int|null $expires) +``` + +Set tracking cookie with the token name and value + +This sends a cookie to the users browser with the configured settings (and if passed, a custom value and expires timestamp) + +**Parameters** + +* `(string|null) $value` +: Defaults to configured value +* `(int|null) $expires` +: Defaults to current time plus configured max age + +**Return Values** + +`self` + +
+ +## Token::removeCookie + +**Description** + +```php +public removeCookie (void) +``` + +Remove tracking cookie + +This sends a cookie to the users browser with the configured settings, but with false as value, and an expiration date set to a time in the past, removing the cookie + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`self` + +
\ No newline at end of file diff --git a/docs/User.md b/docs/User.md new file mode 100644 index 0000000..0a4f04c --- /dev/null +++ b/docs/User.md @@ -0,0 +1,277 @@ +# User + +This class is in charge of managing your users, including associating them with tracking tokens, adding them to groups and controlling their experiment participation, and handling when they have reached a conversion goal. + +| Name | Description | +|------|-------------| +|[getToken](#usergettoken)|Get the current user's token| +|[setToken](#usersettoken)|Set the current user's tracking token| +|[getExperiments](#usergetexperiments)|Get all user experiments| +|[hasExperiment](#userhasexperiment)|Return whether the user has an experiment in the list| +|[isParticipant](#userisparticipant)|Return whether the user is a participant of an experiment, and optionally, part of a specific group| +|[inControl](#userincontrol)|RReturn whether the user belongs to the control group of an experiment| +|[inVariation](#userinvariation)|Return whether the user belongs to the variation group of an experiment| +|[addExperiment](#useraddexperiment)|Add an experiment to the user's list of experiments| +|[shouldParticipate](#usershouldparticipate)|Determine is user should be a participant in an experiment| +|[assignGroup](#userassigngroup)|Get a group that the user should be asssiged to| +|[hasConverted](#userhasconverted)|Get whether a user has converted in a specific experiment| +|[setConverted](#usersetconverted)|Set whether a user has converted in a specific experiment| + +## User::getToken + +**Description** + +```php +public getToken (void) +``` + +Get the current users token + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`Token|null` + +
+ +## User::setToken + +**Description** + +```php +public setToken (Token|array|string|null $token) +``` + +Set the current users tracking token + +If an array is passed, it will be used as configuration to create a new instance of Token, and if a string is passed a new Token instance will be created with this value, and all other configuration options set to default + +If nothing is passed, the same as above will happen, but the value will be null, and will be read from the users cookie (if one exists) + +**Parameters** + +* `(Token|array|string|null) $token` + +**Return Values** + +`self` + +
+ +## User::getExperiments + +**Description** + +```php +public getExperiments (void) +``` + +Get all user experiments + +**Parameters** + +`This function has no parameters.` + +**Return Values** + +`array` + +
+ +## User::hasExperiment + +**Description** + +```php +public hasExperiment (Experiment|int $experiment) +``` + +Return whether the user has an experiment in the list, regardless of whether the user actually is part of it + +**Parameters** + +* `(Experiment|int) $experiment` +: Experiment instance or ID + +**Return Values** + +`bool` + +
+ +## User::isParticipant + +**Description** + +```php +public isParticipant (Experiment|int $experiment, int|null $group) +``` + +Return whether the user is a participant of an experiment, and optionally, part of a specific group + +**Parameters** + +* `(Experiment|int) $experiment` +: Experiment instance or ID +* `(int|null) $group` +: Group type (0 or 1) + +**Return Values** + +`bool` + +
+ +## User::inControl + +**Description** + +```php +public inControl (Experiment|int $experiment) +``` + +Return whether the user belongs to the control group of an experiment + +**Parameters** + +* `(Experiment|int) $experiment` +: Experiment instance or ID + +**Return Values** + +`bool` + +
+ +## User::inVariation + +**Description** + +```php +public inVariation (Experiment|int $experiment) +``` + +Return whether the user belongs to the variation group of an experiment + +**Parameters** + +* `(Experiment|int) $experiment` +: Experiment instance or ID + +**Return Values** + +`bool` + +
+ +## User::addExperiment + +**Description** + +```php +public addExperiment (Experiment $experiment, Group|null $group, bool $converted) +``` + +Add an experiment to the users list of experiments + +If no group is set, the experiment is added to to list without the user being assigned a group + +**Parameters** + +* `(Experiment) $experiment` +* `(Group|null) $group` +* `(bool) $converted` + +**Return Values** + +`self` + +
+ +## User::shouldParticipate + +**Description** + +```php +public shouldParticipate (Experiment $experiment) +``` + +Determine is user should be a participant in an experiment, based on the experiments configured percentual coverage + +**Parameters** + +* `(Experiment) $experiment` + +**Return Values** + +`bool` + +
+ +## User::assignGroup + +**Description** + +```php +public assignGroup (Experiment $experiment) +``` + +Get a group that the user should be asssiged to, either the control or variation (with a 50/50 chance) + +If the experiments coverage is below 100, it's percentage value will be used to determine if the user is assigned an experiment group at all + +**Parameters** + +* `(Experiment) $experiment` + +**Return Values** + +`int|null` + +
+ +## User::hasConverted + +**Description** + +```php +public hasConverted (int $id) +``` + +Get whether a user has converted in a specific experiment + +**Parameters** + +* `(int) $id` +: Experiment ID + +**Return Values** + +`bool` + +
+ +## User::setConverted + +**Description** + +```php +public setConverted (int $id, bool $converted) +``` + +Set whether a user has converted in a specific experiment + +**Parameters** + +* `(int) $id` +: Experiment ID +* `(bool) $converted` + +**Return Values** + +`self` + +
\ No newline at end of file diff --git a/src/Experiment.php b/src/Experiment.php new file mode 100644 index 0000000..f3adb1e --- /dev/null +++ b/src/Experiment.php @@ -0,0 +1,279 @@ + 0, + 'name' => 'control' + ], + [ + 'type' => 1, + 'name' => 'variation' + ] + ]; + + /** + * @param array $config + * + * @return void + */ + public function __construct($config = []) + { + // Merge groups (if defined) with defaults + if (isset($config['groups']) && is_array($config['groups'])) { + for ($i = 0; $i <= 1; $i++) { + if (isset($config['groups'][$i])) { + $this->setGroup($i, $config['groups'][$i]); + } + } + } + + // If not both groups are defined, set them to defaults + for ($i = 0; $i <= 1; $i++) { + if (!$this->getGroup($i)) { + $this->setGroup($i, $this->defaultGroups[$i]); + } + } + + // Set ID (if defined) + if (isset($config['id'])) { + $this->setID($config['id']); + } + + // Set name (if defined) + if (isset($config['name'])) { + $this->setName($config['name']); + } + + // Set coverage (if defined) + if (isset($config['coverage'])) { + $this->setCoverage($config['coverage']); + } + } + + /** + * Get experiment ID + * + * @return mixed|null + */ + public function getID() + { + return $this->id; + } + + /** + * Set experiment ID + * + * @param mixed $id + * + * @return self + */ + public function setID($id) + { + $this->id = $id; + + return $this; + } + + /** + * Get experiment name + * + * @return string|null + */ + public function getName() + { + return $this->name; + } + + /** + * Set experiment name + * + * @param string $name + * + * @return self + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Map group names to their respective index + * + * @param int|string $key + * + * @return int + */ + private function mapGroup($key) + { + if (is_string($key)) { + $index = false; + + foreach ($this->groups as $i => $group) { + if ($group->getName() == $key) { + $index = $i; + } + } + + if ($index === false) { + throw new \Exception('Undefined group "' . $key . '"'); + } + + return $index; + } + + return $key; + } + + /** + * Get both groups + * + * @return array + */ + public function getGroups() + { + return $this->groups; + } + + /** + * Get a specific group + * + * @param int|string $key + * + * @return Group + */ + public function getGroup($key) + { + // Convert group name to index + $key = $this->mapGroup($key); + + // Get specified group + return $this->groups[$key] ?? null; + } + + /** + * Define a group + * + * @param int|string $key + * @param Group|array|object $group + * + * @return self + */ + public function setGroup($key, $group) + { + // Convert group name to index + $key = $this->mapGroup($key); + + // Make group an instance of Group if it isn't already + if (!$group instanceof Group) { + $group = new Group($group); + } + + // Set default name if none is defined + if ($group->getName() === null) { + $group->setName($this->defaultGroups[$key]['name']); + } + + // Set default type if none is defined + if ($group->getType() === null) { + $group->setType($this->defaultGroups[$key]['type']); + } + + $this->groups[$key] = $group; + + return $this; + } + + /** + * Get the control + * + * @return Group + */ + public function getControl() + { + return $this->getGroup(0); + } + + /** + * Define the control + * + * @param Group|array|object $group + * + * @return self + */ + public function setControl($group) + { + return $this->setGroup(0, $group); + } + + /** + * Get the variation + * + * @return Group + */ + public function getVariation() + { + return $this->getGroup(1); + } + + /** + * Define the variation + * + * @param Group|array|object $group + * + * @return self + */ + public function setVariation($group) + { + return $this->setGroup(1, $group); + } + + /** + * Get experiment coverage + * + * @return int + */ + public function getCoverage() + { + return $this->coverage; + } + + /** + * Set experiment coverage + * + * This is the percentual chance that a new user will be included in the experiment + * + * @param int $percent + * + * @return self + */ + public function setCoverage($percent) + { + if ($percent < 0 || $percent > 100) { + throw new \Exception('Invalid $percent (value must be between 0 and 100)'); + } + + $this->coverage = round($percent); + + return $this; + } + + /** + * Return a Result instance from the current experiment + * + * @return Result + */ + public function getResult() + { + return new Result($this); + } +} \ No newline at end of file diff --git a/src/Group.php b/src/Group.php new file mode 100644 index 0000000..be25633 --- /dev/null +++ b/src/Group.php @@ -0,0 +1,268 @@ +group = array_merge([ + 'type' => null, + 'name' => null, + 'size' => 0, + 'conversions' => 0, + 'winner' => null + ], (array)$group); + } + + /** + * Get all values for the group + * + * @return object + */ + public function getValues() + { + $values = array_merge($this->group, [ + 'conversionRate' => $this->getConversionRate() + ]); + + return (object)$values; + } + + /** + * Get property value of group + * + * @param int|string $property + * + * @return mixed + */ + public function getValue($property) + { + return $this->group[$property]; + } + + /** + * Set property value of group + * + * @param string $property + * @param mixed $value + * + * @return self + */ + public function setValue($property, $value) + { + // Set new value + $this->group[$property] = $value; + + // Return self + return $this; + } + + /** + * Get group name + * + * @return null|string + */ + public function getName() + { + return $this->getValue('name'); + } + + /** + * Set group name + * + * @param string $name + * + * @return self + */ + public function setName($name) + { + return $this->setValue('name', $name); + } + + /** + * Get group size + * + * @return int + */ + public function getSize() + { + return $this->getValue('size'); + } + + /** + * Set group size + * + * @param int $size + * + * @return self + */ + public function setSize($size) + { + return $this->setValue('size', $size); + } + + /** + * Get number of conversions for the group + * + * @return int + */ + public function getConversions() + { + return $this->getValue('conversions'); + } + + /** + * Set number of conversions for the group + * + * @param int $conversions + * + * @return self + */ + public function setConversions($conversions) + { + return $this->setValue('conversions', $conversions); + } + + /** + * Get conversion rate for the group + * + * @return float + */ + public function getConversionRate() + { + $size = $this->getSize(); + $conversions = $this->getConversions(); + + if (!$size || !$conversions) { + return 0; + } + + return $conversions / $size; + } + + /** + * Get whether the group is the winner + * + * @return bool + */ + public function isWinner() + { + return $this->getValue('winner') === true; + } + + /** + * Define the group as the winner + * + * @return self + */ + public function setWinner() + { + return $this->setValue('winner', true); + } + + /** + * Get whether the group is the loser + * + * @return bool + */ + public function isLoser() + { + return $this->getValue('winner') === false; + } + + /** + * Define the group as the loser + * + * @return self + */ + public function setLoser() + { + return $this->setValue('winner', false); + } + + /** + * Get group type + * + * Returns 0 for control, 1 for variation and null when type is unknown + * + * @return int|null + */ + public function getType() + { + return $this->getValue('type'); + } + + /** + * Get whether the variation is a specific type + * + * @param int $type + * + * @return bool + */ + public function isType(int $type) + { + return $this->getType() === $type; + } + + /** + * Set group type + * + * @param int $type + * + * @return self + */ + public function setType(int $type) + { + return $this->setValue('type', $type); + } + + /** + * Get whether the group is the control + * + * @return bool + */ + public function isControl() + { + return $this->isType(0); + } + + /** + * Define the group as the control + * + * @return self + */ + public function setControl() + { + return $this->setType(0); + } + + /** + * Get whether the group is the variation + * + * @return bool + */ + public function isVariation() + { + return $this->isType(1); + } + + /** + * Define the group as the variation + * + * @return self + */ + public function setVariation() + { + return $this->setType(1); + } +} \ No newline at end of file diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..113f24c --- /dev/null +++ b/src/Result.php @@ -0,0 +1,417 @@ +experiment = $experiment; + + // Set minimum confidence (if defined) + if (isset($config['minimumConfidence'])) { + $this->setMinimumConfidence($config['minimumConfidence']); + } + + // Set minimum detectable effect (if defined) + if (isset($config['minimumDetectableEffect'])) { + $this->setMinimumDetectableEffect($config['minimumDetectableEffect']); + } + } + + /** + * Calculate result + * + * @return self + */ + private function calculate() + { + $control = $this->experiment->getControl(); + $variation = $this->experiment->getVariation(); + + $controlSize = $control->getSize(); + $controlConversions = $control->getConversions(); + $controlConversionRate = $control->getConversionRate(); + + $variationSize = $variation->getSize(); + $variationConversions = $variation->getConversions(); + $variationConversionRate = $variation->getConversionRate(); + + if ( + ($controlSize != 0 && $variationSize != 0) && + !($controlConversions == 0 && $variationConversions == 0) && + $controlConversionRate !== $variationConversionRate + ) { + $crA = $controlConversionRate; + $crB = $variationConversionRate; + + // Check which variation is the winner + $winner = ($crA > $crB) ? 0 : 1; + + // Set winner and loser groups + $this->experiment->getGroup($winner)->setWinner(); + $this->experiment->getGroup(1 - $winner)->setLoser(); + + // If control is winner, flip experiment and control for the remaining calculations + if ($winner === 0) { + list($controlSize, $variationSize) = array($variationSize, $controlSize); + list($crA, $crB) = array($crB, $crA); + } + + // Calculate standard error + $seA = sqrt(($crA * (1 - $crA)) / $controlSize); + $seB = sqrt(($crB * (1 - $crB)) / $variationSize); + + $seDiff = sqrt(pow($seA, 2) + pow($seB, 2)); + + // Avoid division by zero when calculating zScore and confidence + if (!!($crB - $crA) && !!$seDiff) { + $zScore = ($crB - $crA) / $seDiff; + $confidence = $this->cdf($zScore, 0, 1); + $confident = $confidence >= $this->minimumConfidence; + } else { + $confidence = 0; + $confident = false; + } + + // Calculate minimum sample size + $sampleSize = $this->calculateSampleSize( + $crA, $this->getMinimumConfidence(), $this->getMinimumDetectableEffect() + ); + + /** + * Even if the results are confident (null hypothesis is rejected), the size + * of both groups need to be greater than or equal to the minimum sample size + * to consider the result statistically significant + */ + + $significant = min($controlSize, $variationSize) >= $sampleSize ? $confident : false; + + $this->winner = $winner; + $this->confidence = $confidence; + $this->confident = $confident; + $this->sampleSize = $sampleSize; + $this->significant = $significant; + } else { + $this->winner = null; + $this->confidence = 0; + $this->confident = false; + $this->sampleSize = INF; + $this->significant = false; + } + + return $this; + } + + /** + * Error function + * + * @param float $x + * + * @return float + */ + private function erf($x) + { + $cof = array( + -1.3026537197817094, 6.4196979235649026e-1, 1.9476473204185836e-2, + -9.561514786808631e-3, -9.46595344482036e-4, 3.66839497852761e-4, + 4.2523324806907e-5, -2.0278578112534e-5, -1.624290004647e-6, + 1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8, + 6.529054439e-9, 5.059343495e-9, -9.91364156e-10, + -2.27365122e-10, 9.6467911e-11, 2.394038e-12, + -6.886027e-12, 8.94487e-13, 3.13092e-13, + -1.12708e-13, 3.81e-16, 7.106e-15, + -1.523e-15, -9.4e-17, 1.21e-16, + -2.8e-17 + ); + + $isneg = !1; + $d = 0; + $dd = 0; + + if ($x < 0) { + $x = -$x; + $isneg = !0; + } + + $t = 2 / (2 + $x); + $ty = 4 * $t - 2; + + for ($j = count($cof) - 1; $j > 0; $j--) { + $tmp = $d; + $d = $ty * $d - $dd + $cof[$j]; + $dd = $tmp; + } + + $res = $t * exp(-$x * $x + 0.5 * ($cof[0] + $ty * $d) - $dd); + + return ($isneg) ? $res - 1 : 1 - $res; + } + + /** + * Cumulative distribution function + * + * @param float $zScore + * @param float $mean + * @param float $std + * + * @return float + */ + private function cdf($zScore, $mean, $std) + { + return 0.5 * (1 + $this->erf(($zScore - $mean) / sqrt(2 * $std * $std))); + } + + /** + * Calculate minimum sample size + * + * @param float $controlConversionRate + * @param int $minimumEffect + * @param float minimumConfidence + * + * @return int + */ + private function calculateSampleSize($controlConversionRate, $minimumConfidence, $miminumEffect) + { + if ($controlConversionRate <= 0) { + return INF; + } + + $confidence = 1 - $minimumConfidence; + $effect = $controlConversionRate * ($miminumEffect / 100); + + $c1 = $controlConversionRate; + $c2 = $controlConversionRate - $effect; + $c3 = $controlConversionRate + $effect; + + $t = abs($effect); + + $v1 = $c1 * (1 - $c1) + $c2 * (1 - $c2); + $v2 = $c1 * (1 - $c1) + $c3 * (1 - $c3); + + $e1 = 2 * (1 - $confidence) * $v1 * log(1 + sqrt($v1) / $t) / ($t * $t); + $e2 = 2 * (1 - $confidence) * $v2 * log(1 + sqrt($v2) / $t) / ($t * $t); + + $sampleSize = abs($e1) >= abs($e2) ? $e1 : $e2; + + if ($sampleSize < 0 || !is_numeric($sampleSize)) { + return INF; + } + + $n = round($sampleSize); + $m = pow(10, 2 - floor(log($n) / log(10)) - 1); + + return round(round($n * $m) / $m); + } + + /** + * Get the specified minimum confidence + * + * @return float + */ + public function getMinimumConfidence() + { + return $this->minimumConfidence; + } + + /** + * Set the minimum confidence + * + * @param float $confidence + * + * @return self + */ + public function setMinimumConfidence($confidence) + { + $this->minimumConfidence = $confidence; + + return $this; + } + + /** + * Get the specified minimum detectable effect + * + * @return float + */ + public function getMinimumDetectableEffect() + { + return $this->minimumDetectableEffect; + } + + /** + * Set the minimum detectable effect + * + * @param float $effect + * + * @return self + */ + public function setMinimumDetectableEffect($effect) + { + $this->minimumDetectableEffect = $effect; + + return $this; + } + + /** + * Get the winning group + * + * @return Group|null + */ + public function getWinner() + { + $this->calculate(); + + if ($this->winner === null) { + return null; + } + + return $this->experiment->getGroup($this->winner); + } + + /** + * Get the losing group + * + * @return Group|null + */ + public function getLoser() + { + $this->calculate(); + + if ($this->winner === null) { + return null; + } + + return $this->experiment->getGroup(1 - $this->winner); + } + + /** + * Get the percentual conversion rate change between the control and variation + * + * @return float + */ + public function getConversionRateChange() + { + $controlConversions = $this->experiment->getControl()->getConversions(); + $variationConversions = $this->experiment->getVariation()->getConversions(); + + // Return zero if either or both groups have no conversion + if (!$controlConversions || !$variationConversions) { + return 0; + } + + return round(( + $variationConversions - $controlConversions + ) / $controlConversions * 100, 2); + } + + /** + * Get confidence of result + * + * Returns the probability that the null hypothesis (the hypothesis that there + * is no difference or no change between the two variants) can be rejected + * + * This is called the alternative hypothesis (inverse of the probability value) + * + * @return float + */ + public function getConfidence() + { + $this->calculate(); + + return $this->confidence; + } + + /** + * Return whether we can be confident of the result + * + * This requires the confidence to be greater than or equal to the configured + * minimum confidence + * + * @return bool + */ + public function isConfident() + { + $this->calculate(); + + return $this->confident; + } + + /** + * Return whether the result is statistically significant + * + * This requires both the confidence (alternative hypothesis, i.e. the inverse of the p-value) + * to be high enough (based on the specified minimum confidence) that the null hypothesis + * can be rejected, and the size of both groups to be greater than or equal to the minimum + * sample size (which is in turn calculated based on the minimum detectable effect, and the + * conversion rate of the control group) + * + * @return bool + */ + public function isSignificant() + { + $this->calculate(); + + return $this->significant; + } + + /** + * Get minimum sample size for the current experiment + * + * This is based on the control conversion rate, minimum confidence, + * and minimum detectable change in conversion rate, and is calculated + * using a two-tailed test with a false discovery rate control + * + * @return int + */ + public function getMinimumSampleSize() + { + $this->calculate(); + + return $this->sampleSize; + } + + /** + * Get complete results + * + * @return object + */ + public function getAll() + { + $this->calculate(); + + // Get winning and losing groups + $winner = $this->getWinner(); + $loser = $this->getLoser(); + + // Return complete results + return (object)[ + 'winner' => $winner ? $winner->getValues() : null, + 'loser' => $loser ? $loser->getValues() : null, + 'conversionRateChange' => $this->getConversionRateChange(), + 'confidence' => $this->confidence, + 'minimumSampleSize' => $this->sampleSize, + 'confident' => $this->confident, + 'significant' => $this->significant + ]; + } +} \ No newline at end of file diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..62f7885 --- /dev/null +++ b/src/Token.php @@ -0,0 +1,155 @@ +config = array_merge([ + 'name' => 'abby-token', + 'value' => null, + 'maxAge' => 60 * 60 * 24 * 365, + 'path' => '/', + 'domain' => null, + 'secure' => null, + 'httpOnly' => null + ], $config); + + if ($this->config['value'] === null) { + $this->config['value'] = $_COOKIE[$this->config['name']] ?? null; + } else { + $this->setValue($this->config['value']); + } + } + + /** + * Get tracking token name + * + * @return string|null + */ + public function getName() + { + return $this->config['name']; + } + + /** + * Set tracking token name + * + * @param string $name + * + * @return self + */ + public function setName($name) + { + $this->config['name'] = $name; + + return $this; + } + + /** + * Get tracking token value + * + * @return string|null + */ + public function getValue() + { + return $this->config['value']; + } + + /** + * Set tracking token value + * + * @param string $value + * + * @return self + */ + public function setValue($value) + { + $this->config['value'] = $value; + + return $this; + } + + /** + * Set the token value to a new generated hash + * + * Use $id to generate a static hash that will always be the same + * given the current token name and identifier + * This is useful for creating a token based on e.g. a user ID + * + * If no identifier is passed, a unique hash is randomly generated + * + * @param mixed $id Unique identifier (e.g. a user ID) + * + * @return self + */ + public function generate($id = null) + { + if ($id !== null) { + $this->setValue(sha1($this->getName() . $id)); + } else { + $this->setValue(sha1($this->getName() . uniqid(mt_rand(), true))); + } + + return $this; + } + + /** + * Set tracking cookie with the token name and value + * + * This sends a cookie to the users browser with the configured settings (and if + * passed, a custom value and expires timestamp) + * + * @param string|null $value Defaults to configured value + * @param int|null $expires Defaults to current time plus configured max age + * + * @return self + */ + public function setCookie($value = null, $expires = null) + { + if ($value === null) { + $value = $this->config['value']; + } + + if ($expires === null) { + $expires = time() + $this->config['maxAge']; + } + + setcookie( + $this->config['name'], + $value, + $expires, + $this->config['path'], + $this->config['domain'], + $this->config['secure'], + $this->config['httpOnly'] + ); + + return $this; + } + + /** + * Remove tracking cookie + * + * This sends a cookie to the users browser with the configured settings, + * but with false as value, and an expiration date set to a time in the + * past, removing the cookie + * + * @return self + */ + public function removeCookie() + { + return $this->setCookie(false, 1); + } +} \ No newline at end of file diff --git a/src/User.php b/src/User.php new file mode 100644 index 0000000..d97edf4 --- /dev/null +++ b/src/User.php @@ -0,0 +1,294 @@ +setToken($config['token']); + } + + // Add experiments (if defined) + if (isset($config['experiments'])) { + foreach ($config['experiments'] as $experiment) { + $this->addExperiment($experiment); + } + } + } + + /** + * Get the current users token + * + * @return Token|null + */ + public function getToken() + { + return $this->token; + } + + /** + * Set the current users tracking token + * + * If an array is passed, it will be used as configuration + * to create a new instance of Token, and if a string is passed + * a new Token instance will be created with this value and all + * other configuration options set to default + * + * If nothing is passed, the same as above will happen, but the + * value will be null, and will be read from the users cookie + * (if one exists) + * + * @param Token|array|string|null $token + * + * @return self + */ + public function setToken($token = null) + { + if (!$token instanceof Token) { + if (!is_array($token)) { + $token = $token ? ['value' => $token] : []; + } + + $token = new Token($token); + } + + $this->token = $token; + + return $this; + } + + /** + * Get all user experiments + * + * @return array + */ + public function getExperiments() + { + return $this->experiments; + } + + /** + * Return whether the user has an experiment in it's experiment list, + * regardless of whether the user actually is part of it + * + * @param Experiment|int $experiment Experiment instance or ID + * + * @return bool + */ + public function hasExperiment($experiment) + { + if ($experiment instanceof Experiment) { + $id = $experiment->id; + } else if (is_int($experiment)) { + $id = $experiment; + } else { + throw new \Exception( + 'Invalid $experiment (type must be Abby\Experiment or int)' + ); + } + + foreach ($this->experiments as $item) { + if ($item->id == $id) { + return true; + } + } + + return false; + } + + /** + * Return whether the user is a participant of an experiment (belongs to + * either of the groups), and if passed, part of a specific group + * + * @param Experiment|int $experiment Experiment instance or ID + * @param int|null $group Group type (0 or 1) + * + * @return bool + */ + public function isParticipant($experiment, $group = null) + { + if ($experiment instanceof Experiment) { + $id = $experiment->id; + } else if (is_int($experiment)) { + $id = $experiment; + } else { + throw new \Exception( + 'Invalid $experiment (type must be Abby\Experiment or int)' + ); + } + + foreach ($this->experiments as $item) { + if ($item->id == $id) { + if ($group === null && $item->group !== null) { + return true; + } else if ($group !== null && $item->group === $group) { + return true; + } + } + } + + return false; + } + + /** + * Return whether the user belongs to the control group of an experiment + * + * @param Experiment|int $experiment Experiment instance or ID + * + * @return bool + */ + public function inControl($experiment) + { + return $this->isParticipant($experiment, 0); + } + + /** + * Return whether the user belongs to the variation group of an experiment + * + * @param Experiment|int $experiment Experiment instance or ID + * + * @return bool + */ + public function inVariation($experiment) + { + return $this->isParticipant($experiment, 1); + } + + /** + * Add an experiment to the user's list of experiments + * + * If no group is set, the experiment is added to to list without the user being assigned a group + * + * @param Experiment $experiment + * @param Group|null $group + * @param bool $converted + * + * @return self + */ + public function addExperiment(Experiment $experiment, $group = null, $converted = false) + { + // Setup user experiment array + $userExperiment = (object)[ + 'id' => $experiment->getID() + ]; + + // Add group type if a group is set + $userExperiment->group = $group ? $group->getType() : null; + + // Only set converted value if the user is part of an experiment group + if ($group) { + $userExperiment->converted = $converted; + } + + // Add to list of experiments + $this->experiments[] = $userExperiment; + + return $this; + } + + /** + * Determine is user should be a participant in an experiment, based on the + * experiments configured percentual coverage + * + * @param Experiment $experiment + * + * @return bool + */ + public function shouldParticipate(Experiment $experiment) + { + // Get experiments percentual coverage + $coverage = $experiment->getCoverage(); + + // Return true if coverage is 100% + if ($coverage == 100) { + return true; + } + + // Create an array of a 100 positive and negative numbers, based + // on the value of $coverage + $slots = str_split( + str_repeat(1, $coverage) . str_repeat(0, 100 - $coverage) + ); + + // Shuffle all the slots + shuffle($slots); + + // Return whether the randomly selected slot is a positive number + return $slots[mt_rand(0, 99)]; + } + + /** + * Get a group that the used should be asssiged to, either the control + * or variation (with a 50/50 chance) + * + * If the experiments coverage is below 100, its percentage value will be + * used to determine if the user is assigned an experiment group at all + * + * @param Experiment $experiment + * + * @return int|null + */ + public function assignGroup(Experiment $experiment) + { + if ($this->shouldParticipate($experiment)) { + // If the user is selected, randomly choose either group + return $experiment->getGroup(mt_rand(0, 1)); + } else { + // Otherwise return no group + return null; + } + } + + /** + * Get whether a user has converted in a specific experiment + * + * @param int $id Experiment ID + * + * @return bool + */ + public function hasConverted($id) + { + foreach ($this->experiments as $i => $experiment) { + if ($experiment->id == $id) { + if ($this->experiments[$i]->converted) { + return true; + } + } + } + + return false; + } + + /** + * Set whether a user has converted in a specific experiment + * + * @param int $id Experiment ID + * @param bool $converted + * + * @return self + */ + public function setConverted($id, $converted = true) + { + foreach ($this->experiments as $i => $experiment) { + if ($experiment->id == $id) { + $this->experiments[$i]->converted = $converted; + } + } + + return $this; + } +} \ No newline at end of file