# Apptus A/B Test
> This is only for advanced developers and should only be considered if you are quite familiar with the Shopware core
> The following code assumes you're using the composer setup of Shopware5. If not, you need to make some adjustments.
> Of course you can adjust the code to your needs.
## Basic concept
If you want to do an A/B test with and without Apptus, you need a mechanism to deactivate the plugin on a per user base.
Shopware is basically not capable of doing that, as the set of loaded plugins is determined by the database table `s_core_plugins`.
Plugins also contain modifications on style and templates, which is compiled in cache files. Luckily cache files are environment specific. The default environment used by Shopware5 is called `production`.
Therefore we need 2 things:
1) An alternative plugin initializer capable of loading different sets of plugins
2) 2 Shopware environments on the same instance which we can switch depending on the customer.
## Plugin initializer
`/Library/Apptus.php` (path is arbitrary you can choose anything you want, but sync it with the `require` inclusions)
```php
<?php
namespace Library\Apptus;
use PDO;
use Shopware\Bundle\PluginInstallerBundle\Service\PluginInitializer as PluginInitializerOriginal;
use Symfony\Component\Console\Input\ArgvInput;
class PluginInitializer extends PluginInitializerOriginal
{
/**
* @var PDO
*/
private $connection;
/**
* @var string
*/
private $pluginDirectory;
/**
* @var array[]
*/
private $activePlugins = [];
/**
* @var string
*/
const BLUE_GREEN_COOKIE_NAME = 'ki-enabled';
/**
* @var string
*/
const PROBABILITY = 'APPTUS_PROBABILITY';
/**
* @var array
*/
private $pluginsToDeactivateBlue = [
'ApptusESales',
];
/**
* @var array
*/
private $pluginsToDeactivateGreen = [
'SwagFuzzy',
];
/**
* PluginInitializer constructor.
*
* @param PDO $connection
* @param string $pluginDirectory
*/
public function __construct(PDO $connection, $pluginDirectory)
{
parent::__construct($connection, $pluginDirectory);
}
/**
* @return \Shopware\Components\Plugin[]
* @throws \Exception
*/
public function initializePlugins()
{
$plugins = parent::initializePlugins();
$this->activePlugins = parent::getActivePlugins();
// Ensure plugin loading order is consistent
ksort($this->activePlugins);
ksort($plugins);
// Use the given env argument on CLI
if (php_sapi_name() === 'cli') {
$input = new ArgvInput();
$env = $input->getParameterOption(array('--env', '-e'), getenv('SHOPWARE_ENV') ?: 'production');
if (strpos($env, '_blue')) {
return $this->deactivatePlugins($plugins, $this->pluginsToDeactivateBlue);
} elseif (strpos($env, '_green')) {
return $this->deactivatePlugins($plugins, $this->pluginsToDeactivateGreen);
}
return $plugins;
}
// Check for A/B cookie or randomly assign it
static::initializeTestingCookie();
// If group is blue, deactivate list of plugins
$pluginsToDeactivate = $this->pluginsToDeactivateGreen;
if (!self::testPluginsActive()) {
$pluginsToDeactivate = $this->pluginsToDeactivateBlue;
}
return $this->deactivatePlugins($plugins, $pluginsToDeactivate);
}
private function deactivatePlugins(array $plugins, array $pluginsToDeactivate)
{
foreach ($pluginsToDeactivate as $pluginToDeactivate) {
if (isset($plugins[$pluginToDeactivate])) {
unset($plugins[$pluginToDeactivate]);
}
if (isset($this->activePlugins[$pluginToDeactivate])) {
unset($this->activePlugins[$pluginToDeactivate]);
}
}
return $plugins;
}
/**
* Initialize the A/B switching cookie.
* This is used in the modified Shopware initialization (shopware.php)
* to dynamically switch environment.
*
* @throws \Exception
*/
public static function initializeTestingCookie()
{
if (php_sapi_name() === 'cli') {
return;
}
// Internal employees can use ?ki-enabled=0 internally to decide which version they want (for testing)
if (isset($_REQUEST[self::BLUE_GREEN_COOKIE_NAME])) {
$_COOKIE[self::BLUE_GREEN_COOKIE_NAME] = $_REQUEST[self::BLUE_GREEN_COOKIE_NAME] === '0' ? 0 : 1;
setcookie(self::BLUE_GREEN_COOKIE_NAME, $_COOKIE[self::BLUE_GREEN_COOKIE_NAME], time() + 60 * 60 * 24 * 30, '/');
return;
}
// Is deactivated?
$deactivated = false;
if ((int)getenv(self::PROBABILITY) === 0) {
$deactivated = true;
}
if ($deactivated) {
// Deactivate AI
$_COOKIE[self::BLUE_GREEN_COOKIE_NAME] = 0;
setcookie(self::BLUE_GREEN_COOKIE_NAME, $_COOKIE[self::BLUE_GREEN_COOKIE_NAME], time() + 60 * 60 * 24 * 30, '/');
return;
}
// Is activated?
$activated = false;
if ((int)getenv(self::PROBABILITY) === 100) {
$activated = true;
}
if ($activated) {
// Activate AI
$_COOKIE[self::BLUE_GREEN_COOKIE_NAME] = 1;
setcookie(self::BLUE_GREEN_COOKIE_NAME, $_COOKIE[self::BLUE_GREEN_COOKIE_NAME], time() + 60 * 60 * 24 * 30, '/');
return;
}
// Assign new user to a group
if (!isset($_COOKIE[self::BLUE_GREEN_COOKIE_NAME])) {
// Distribution according to ENV variable
$randomNumber = rand(1, 100);
$value = 1;
$probability = 50;
if (is_numeric(getenv(self::PROBABILITY))) {
$probability = (int)getenv(self::PROBABILITY);
}
// E.g. prob 70%, only numbers above 70 should deactivate (= 30%)
if ($randomNumber > $probability) {
$value = 0;
}
// Bots should not get Apptus
if (isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/bot|crawl|slurp|spider|mediapartners/i', $_SERVER['HTTP_USER_AGENT'])) {
$value = 0;
}
$_COOKIE[self::BLUE_GREEN_COOKIE_NAME] = $value;
setcookie(self::BLUE_GREEN_COOKIE_NAME, $_COOKIE[self::BLUE_GREEN_COOKIE_NAME], time() + 60 * 60 * 24 * 30, '/');
}
}
/**
* @return bool
*/
public static function testPluginsActive()
{
return intval($_COOKIE[self::BLUE_GREEN_COOKIE_NAME]) === 1;
}
/**
* @return array
*/
public function getActivePlugins()
{
return $this->activePlugins;
}
}
```
This class is built to override the default `PluginInitializer` of shopware.
This can be accomplished by a patch like this:
```
Index: /app/engine/Shopware/Kernel.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- /app/engine/Shopware/Kernel.php (date 1535711720000)
+++ /app/engine/Shopware/Kernel.php (date 1535711720000)
@@ -451,7 +451,8 @@
protected function initializePlugins()
{
- $initializer = new PluginInitializer(
+ require_once __DIR__ . '/../../../../../Library/Apptus/PluginInitializer.php';
+ $initializer = new \Library\Apptus\PluginInitializer(
$this->connection,
$this->config['plugin_directories']['ShopwarePlugins']
);
```
This will use the `PluginInitializer` above instead of the regular one.
**What it does:**
* Provide an ENV variable `APPTUS_PROBABILITY` which can be an integer between 0 and 100. It indicates a percentual distribution of users to either with or without Apptus. Setting the value to `0` or `100` will forcefully assign every user to the group (even if they were previously assigned to the other => kind of kill-switch)
* Which group a user belongs to is managed by a cookie called `ki-enabled=0/1`
* It generates new Shopware environments `production_blue` and `production_green` to have separate compiled themes and cache files
## Configuring the ENV variable
As mentioned above, the distribution of visitors getting either Apptus or not depends on the setting of the variable `APPTUS_PROBABILITY` to a value between 0 and 100. E.g. `APPTUS_PROBABILITY=30` means that 30% of the visitors will get the Apptus enabled version of the page and 70% won't.
To set the environment variable, you can use different methods:
### .htaccess
```
SetEnv APPTUS_PROBABILITY 50
```
### Modify shopware.php
Insert into the top of the file:
```php
putenv('APPTUS_PROBABILITY=50');
```
### Composer environment
Add to your `.env` or `.env.local` file
```
APPTUS_PROBABILITY=50
```
### Apache
Modify the file `/etc/apache2/envvars` and add:
```
export APPTUS_PROBABILITY=50
```
### PHP-FPM
Add to your pool config (usually `/etc/php/fpm/pool.d/www.conf`):
```
env[APPTUS_PROBABILITY] = 50
```
## Using separate environments
We must now tell Shopware to use the assigned enviornment when the Kernel is initialized. To do this, we must modify the `shopware.php`.
```php
<?php
use Shopware\Components\HttpCache\AppCache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\TerminableInterface;
/**
* @var Composer\Autoload\ClassLoader
*/
$loader = require __DIR__.'/app/autoload.php';
$environment = getenv('SHOPWARE_ENV');
// BEGIN MOD
$modifiedEnv = $environment;
require_once __DIR__ . '/Library/Apptus/PluginInitializer.php';
if (php_sapi_name() !== 'cli') {
\Library\Apptus\PluginInitializer::initializeTestingCookie();
$modifiedEnv = \Library\Apptus\PluginInitializer::testPluginsActive() === true ? $modifiedEnv . '_green' : $modifiedEnv . '_blue';
}
$kernel = new AppKernel($modifiedEnv, $environment !== 'production');
if ($kernel->isHttpCacheEnabled() && !\Library\Apptus\PluginInitializer::testPluginsActive()) {
$kernel = new AppCache($kernel, $kernel->getHttpCacheConfig());
}
// END MOD
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
if ($kernel instanceof TerminableInterface) {
$kernel->terminate($request, $response);
}
```
## Separate theme caches
Unfortunately Shopware assumes, that you only use 1 environment and will delete all compiled JS and Less files. Those files don't depend on the environment.
But we can fix this with a simple workaround:
```
Index: /app/engine/Shopware/Components/Theme/Compiler.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- /app/engine/Shopware/Components/Theme/Compiler.php (date 1552906776000)
+++ /app/engine/Shopware/Components/Theme/Compiler.php (date 1552906776000)
@@ -358,7 +358,7 @@
$this->pathResolver->buildTimestampName($timestamp, $shop, 'js'),
];
- $this->clearDirectory($files);
+// $this->clearDirectory($files);
}
/**
```
This will prevent Shopware from deleting compiled JS / CSS files.
But, we must also tell Shopware to have different CSS/JS files per environment:
```
Index: /app/engine/Shopware/Components/Theme/PathResolver.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- /app/engine/Shopware/Components/Theme/PathResolver.php (date 1552906885000)
+++ /app/engine/Shopware/Components/Theme/PathResolver.php (date 1552906885000)
@@ -361,7 +361,7 @@
$shop = $shop->getMain();
}
- $filename = $timestamp . '_' . md5($timestamp . $shop->getTemplate()->getId() . $shop->getId() . $this->release->getRevision());
+ $filename = $timestamp . '_' . Shopware()->Environment() . '_' . md5($timestamp . $shop->getTemplate()->getId() . $shop->getId() . $this->release->getRevision());
return $filename . '.' . $suffix;
}
```
## Tracking
As you may want to trace the results of your A/B Test, you can use your favorite tracking tool e.g. Google Analytics and segment your visitors according to the cookie `ki-enabled` (or whatever you want to use in the code above).
### Google Analytics
For Google Analytics, you should check on how to define a targeting group by first-party cookie: https://support.google.com/optimize/answer/6301788?hl=en
### Shopware
If you want to track if the user used Apptus or not in a sale, you should develop a custom plugin, that adds an attribute to the order and stores the value of the cookie.
```php
class Tracking extends Plugin
{
public function install(InstallContext $context)
{
$this->setCustomAttributes();
}
public function update(UpdateContext $context)
{
$this->setCustomAttributes();
}
protected function setCustomAttributes()
{
/** @var \Shopware\Bundle\AttributeBundle\Service\CrudService $service */
$service = $this->container->get('shopware_attribute.crud_service');
$service->update('s_order_attributes', 'a_b_tracking', 'string', [
'label' => 'Tracking',
'displayInBackend' => true,
'translatable' => false,
'custom' => false,
], 'a_b_tracking', true);
//Generate attributes
Shopware()->Models()->generateAttributeModels(['s_order_attributes']);
}
}
```
And store the value using a subscriber:
```php
class TrackingSubscriber implements SubscriberInterface
{
public static function getSubscribedEvents()
{
return [
'Shopware_Modules_Order_SaveOrder_FilterDetailAttributes' => 'onSaveOrderDetails',
];
}
public function onSaveOrderDetails(\Enlight_Event_EventArgs $args)
{
/** @var array $basketRow */
$basketRow = $args->get('basketRow');
/** @var array $attributeData */
$attributeData = $args->getReturn();
$attributeData['a_b_tracking'] = PluginInitializer::testPluginsActive() ? 'a' : 'b';
$args->setReturn($attributeData);
}
}
```