545 views
# 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); } } ```