add abtest feature

This commit is contained in:
Simon Vieille 2022-05-20 13:50:04 +02:00
parent e91a395c34
commit 3d6f6bb7b3
Signed by: deblan
GPG Key ID: 579388D585F70417
13 changed files with 454 additions and 2 deletions

View File

@ -0,0 +1,30 @@
<?php
namespace App\Core\Ab;
/**
* class AbContainer.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class AbContainer
{
protected array $tests = [];
public function add(AbTest $test): self
{
$this->tests[$test->getName()] = $test;
return $this;
}
public function has(string $name): bool
{
return isset($this->tests[$name]);
}
public function get(string $name): AbTest
{
return $this->tests[$name];
}
}

125
src/core/Ab/AbTest.php Normal file
View File

@ -0,0 +1,125 @@
<?php
namespace App\Core\Ab;
/**
* class AbTest.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class AbTest
{
protected $results;
protected string $name;
protected array $variations = [];
protected array $probabilities = [];
protected int $duration = 3600 * 24;
public function __construct(string $name)
{
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
public function getResult()
{
return $this->result;
}
public function setResult(string $result): self
{
$this->result = $result;
return $this;
}
public function isValidVariation($variation): bool
{
return array_key_exists($variation, $this->variations);
}
public function addVariation(string $name, $value, int $probability = null): self
{
$this->variations[$name] = $value;
$this->probabilities[$name] = $probability;
return $this;
}
public function getVariation($variation)
{
return $this->variations[$variation];
}
public function getResultValue()
{
return $this->getVariation($this->getResult());
}
public function setDuration(int $duration): self
{
$this->duration = $duration;
return $this;
}
public function getDuration(): int
{
return $this->duration;
}
public function run(): self
{
$this->result = $this->chance();
return $this;
}
protected function chance(): string
{
$sum = 0;
$empty = 0;
foreach ($this->probabilities as $name => $value) {
$sum += $value;
if (empty($value)) {
++$empty;
}
}
if ($sum > 100) {
throw new \LogicException('Test Error: Total variation probabilities is bigger than 100%');
}
if ($sum < 100) {
foreach ($this->probabilities as $name => $value) {
if (empty($value)) {
$this->probabilities[$name] = (100 - $sum) / $empty;
}
}
}
krsort($this->probabilities);
$number = \rand(0, (int) \array_sum($this->probabilities) * 10);
$starter = 0;
$return = '';
foreach ($this->probabilities as $key => $val) {
$starter += $val * 10;
if ($number <= $starter) {
$return = $key;
break;
}
}
return $return;
}
}

View File

@ -166,6 +166,16 @@ class Node implements EntityInterface
*/
private $securityOperator = 'or';
/**
* @ORM\Column(type="boolean", options={"default"=0})
*/
private $hasAbTest = false;
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $abTestCode;
public function __construct()
{
$this->children = new ArrayCollection();
@ -673,4 +683,28 @@ class Node implements EntityInterface
return $this;
}
public function getHasAbTest(): ?bool
{
return $this->hasAbTest;
}
public function setHasAbTest(bool $hasAbTest): self
{
$this->hasAbTest = $hasAbTest;
return $this;
}
public function getAbTestCode(): ?string
{
return $this->abTestCode;
}
public function setAbTestCode(?string $abTestCode): self
{
$this->abTestCode = $abTestCode;
return $this;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Core\Event\Ab;
use App\Core\Ab\AbTest;
use Symfony\Contracts\EventDispatcher\Event;
/**
* class AbTestEvent.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class AbTestEvent extends Event
{
public const INIT_EVENT = 'ab_test.init';
public const RUN_EVENT = 'ab_test.run';
protected AbTest $test;
public function __construct(AbTest $test)
{
$this->test = $test;
}
public function getTest(): AbTest
{
return $this->test;
}
}

View File

@ -21,7 +21,7 @@ class PasswordRequestEvent extends Event
$this->user = $user;
}
public function getUser(): USer
public function getUser(): User
{
return $this->user;
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Core\EventListener;
use App\Core\Ab\AbContainer;
use App\Core\Ab\AbTest;
use App\Core\Event\Ab\AbTestEvent;
use App\Core\Repository\Site\NodeRepository;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
* class AbListener.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class AbListener
{
protected NodeRepository $nodeRepository;
protected EventDispatcherInterface $eventDispatcher;
protected AbContainer $container;
public function __construct(
NodeRepository $nodeRepository,
AbContainer $container,
EventDispatcherInterface $eventDispatcher
) {
$this->nodeRepository = $nodeRepository;
$this->eventDispatcher = $eventDispatcher;
$this->container = $container;
}
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
if (!$request->attributes->has('_node')) {
return;
}
$node = $this->nodeRepository->findOneBy([
'id' => $request->attributes->get('_node'),
]);
if (!$node) {
return;
}
if (!$node->getHasAbTest()) {
return;
}
if (!$node->getAbTestCode()) {
return;
}
$cookieName = md5('ab_test_'.$node->getAbTestCode());
$cookieValue = $request->cookies->get($cookieName);
$abTest = new AbTest($node->getAbTestCode());
$event = new AbTestEvent($abTest);
$this->container->add($abTest);
$this->eventDispatcher->dispatch($event, AbTestEvent::INIT_EVENT);
if (!$abTest->isValidVariation($cookieValue)) {
$abTest->run();
$result = $abTest->getResult();
$attributes = array_merge($request->attributes->get('ab_test_cookies', []), [
$cookieName => ['value' => $result, 'duration' => $abTest->getDuration()],
]);
$request->attributes->set('ab_test_cookies', $attributes);
$this->eventDispatcher->dispatch($event, AbTestEvent::RUN_EVENT);
} else {
$abTest->setResult($cookieValue);
}
}
public function onKernelResponse(ResponseEvent $event)
{
$cookies = $event->getRequest()->attributes->get('ab_test_cookies', []);
foreach ($cookies as $name => $value) {
$cookie = Cookie::create($name, $value['value'], time() + $value['duration']);
$event->getResponse()->headers->setCookie($cookie);
}
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Core\EventSubscriber;
use App\Core\Event\Ab\AbTestEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* class AbEventSubscriber.
*
* @author Simon Vieille <simon@deblan.fr>
*/
abstract class AbEventSubscriber implements EventSubscriberInterface
{
protected static int $priority = 0;
public static function getSubscribedEvents()
{
return [
AbTestEvent::INIT_EVENT => ['onInit', self::$priority],
AbTestEvent::RUN_EVENT => ['onRun', self::$priority],
];
}
public function onInit(AbTestEvent $event)
{
}
public function onRun(AbTestEvent $event)
{
}
}

View File

@ -154,6 +154,37 @@ class NodeType extends AbstractType
);
}
$builder->add(
'hasAbTest',
CheckboxType::class,
[
'label' => 'Enable AB Testing',
'required' => false,
]
);
$builder->add(
'abTestCode',
TextType::class,
[
'label' => 'Code',
'required' => $builder->getData()->getHasAbTest(),
]
);
$builder->add(
'securityOperator',
ChoiceType::class,
[
'label' => 'Condition',
'required' => true,
'choices' => [
'At least one role' => 'or',
'All roles' => 'and',
],
]
);
$actions = [
'New page' => 'new',
'Use an existing page' => 'existing',

View File

@ -28,3 +28,4 @@ require('./modules/file-picker.js')()
require('./modules/analytics.js')()
require('./modules/page.js')()
require('./modules/sidebar.js')()
require('./modules/node.js')()

View File

@ -147,7 +147,7 @@ const doInitEditor = () => {
editorContainer.attr('id', id)
element.hide()
let data = {time: null, blocks: []};
let data = { time: null, blocks: [] }
try {
const value = JSON.parse(element.val())

View File

@ -0,0 +1,20 @@
const $ = require('jquery')
const abTestChecker = () => {
$('body').on('change', '#node_hasAbTest', () => {
const checkbox = document.querySelector('#node_hasAbTest')
const code = document.querySelector('#node_abTestCode')
code.parentNode.parentNode.classList.toggle('d-none', !checkbox.checked)
if (checkbox.checked) {
code.setAttribute('required', 'required')
} else {
code.removeAttribute('required')
}
})
}
module.exports = () => {
$(abTestChecker)
}

View File

@ -217,6 +217,15 @@
</div>
</div>
<div class="row">
<div class="col-md-12">
{{ form_row(form.hasAbTest) }}
</div>
<div class="col-md-6 {% if not form.vars.data.abTestCode %}d-none{% endif %}">
{{ form_row(form.abTestCode) }}
</div>
</div>
{% if form.securityRoles is defined %}
<div class="pb-3">
<details>

View File

@ -0,0 +1,48 @@
<?php
namespace App\Core\Twig\Extension;
use App\Core\Ab\AbContainer;
use App\Core\Ab\AbTest;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class AbTestExtension extends AbstractExtension
{
protected AbContainer $container;
public function __construct(AbContainer $container)
{
$this->container = $container;
}
public function getFunctions(): array
{
return [
new TwigFunction('ab_test_exists', [$this, 'exists']),
new TwigFunction('ab_test', [$this, 'get']),
new TwigFunction('ab_test_result', [$this, 'result']),
new TwigFunction('ab_test_value', [$this, 'value']),
];
}
public function exists(string $name): bool
{
return $this->container->has($name);
}
public function get(string $name): AbTest
{
return $this->container->get($name);
}
public function result(string $name): string
{
return $this->get($name)->getResult();
}
public function value(string $name)
{
return $this->get($name)->getResultValue();
}
}