├── phpunit └── Feature │ ├── data │ └── world │ │ ├── etsy_aux.yml │ │ └── etsy_index.yml │ ├── LoggerTest.php │ ├── WorldTest.php │ └── ConfigTest.php ├── BRANCHES.md ├── Feature ├── Util.php ├── Logger.php ├── World │ └── Mobile.php ├── Instance.php ├── World.php ├── JSON.php ├── Lint.php └── Config.php ├── LICENSE ├── GENERALIZING.md ├── Feature.php └── README.md /phpunit/Feature/data/world/etsy_aux.yml: -------------------------------------------------------------------------------- 1 | staff: 2 | - 3 | id: 3 4 | auth_username: 'staff_member' 5 | create_date: 0 6 | update_date: 0 7 | -------------------------------------------------------------------------------- /phpunit/Feature/data/world/etsy_index.yml: -------------------------------------------------------------------------------- 1 | users_index: 2 | - 3 | user_id: 1 4 | user_shard: 1 5 | login_name: peter 6 | primary_email: foo@etsycorp.com 7 | is_admin: 0 8 | - 9 | user_id: 2 10 | user_shard: 1 11 | login_name: paul 12 | primary_email: bar@etsycorp.com 13 | is_admin: 1 14 | 15 | -------------------------------------------------------------------------------- /BRANCHES.md: -------------------------------------------------------------------------------- 1 | # Branches 2 | 3 | There are three branches in this repo. 4 | 5 | - etsy -- A snapshot of the code from the actual Etsy sourcecode. 6 | 7 | - master -- The same as `etsy` except with most (all?) of the dependencies on other Etsy code stripped out. 8 | 9 | - generalized -- A quick, untested attempt to generalize the code for non-Etsy contexts. 10 | -------------------------------------------------------------------------------- /Feature/Util.php: -------------------------------------------------------------------------------- 1 | _udid = $udid; 19 | $this->_userID = $userID; 20 | } 21 | 22 | public function uaid() { 23 | return $this->_udid; 24 | } 25 | 26 | public function userID () { 27 | return $this->_userID; 28 | } 29 | 30 | public function log ($name, $variant, $selector) { 31 | parent::log($name, $variant, $selector); 32 | 33 | $this->_name = $name; 34 | $this->_variant = $variant; 35 | $this->_selector = $selector; 36 | } 37 | 38 | public function getLastName() { 39 | return $this->_name; 40 | } 41 | 42 | public function getLastVariant() { 43 | return $this->_variant; 44 | } 45 | 46 | public function getLastSelector() { 47 | return $this->_selector; 48 | } 49 | 50 | public function clearLastFeature() { 51 | $this->_selector = null; 52 | $this->_name = null; 53 | $this->_variant = null; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Feature/Instance.php: -------------------------------------------------------------------------------- 1 | assertEquals('', Feature_Logger::getGAJavascript(array())); 10 | } 11 | 12 | function testLogOne() { 13 | $selections = array(); 14 | $selections[] = array('TEST_key1', 'TEST_var1', 123); 15 | $js = Feature_Logger::getGAJavascript($selections); 16 | $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1', 3]);", $js); 17 | } 18 | 19 | function testLogTwo() { 20 | $selections = array(); 21 | $selections[] = array('TEST_key1', 'TEST_var1', 123); 22 | $selections[] = array('foo', 'bar', 123); 23 | $js = Feature_Logger::getGAJavascript($selections); 24 | $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1..foo.bar', 3]);", $js); 25 | } 26 | 27 | function testTooLong() { 28 | $selections = array(); 29 | $pairs = array(); 30 | foreach (array('a', 'b', 'c', 'd', 'e') as $x) { 31 | $selections[] = array($x, 'xxxxxxxxxx', 123); 32 | $pairs[] = "$x.xxxxxxxxxx"; 33 | } 34 | // This one should not be included in the Javascript because 35 | // we already have 12*5=60 chars and this pair would add three 36 | // more pushing us over the limit of 62. 37 | $selections[] = array('f', 'x', 123); 38 | $value = implode('..', $pairs); 39 | $js = Feature_Logger::getGAJavascript($selections); 40 | $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', '$value', 3]);", $js); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Feature/World.php: -------------------------------------------------------------------------------- 1 | _logger = $logger; 18 | } 19 | 20 | /* 21 | * Get the config value for the given key. 22 | */ 23 | public function configValue($name, $default = null) { 24 | return $default; // IMPLEMENT FOR YOUR CONTEXT 25 | } 26 | 27 | /** 28 | * UAID of the current request. 29 | */ 30 | public function uaid() { 31 | return null; // IMPLEMENT FOR YOUR CONTEXT 32 | } 33 | 34 | /** 35 | * User ID of the currently logged in user or null. 36 | */ 37 | public function userID () { 38 | return null; // IMPLEMENT FOR YOUR CONTEXT 39 | } 40 | 41 | /** 42 | * Login name of the currently logged in user or null. Needs the 43 | * ORM. If we're running as part of an Atlas request we ignore the 44 | * passed in userID and return instead the Atlas user name. 45 | */ 46 | public function userName ($userID) { 47 | return null; // IMPLEMENT FOR YOUR CONTEXT 48 | } 49 | 50 | /** 51 | * Is the given user a member of the given group? (This currently, 52 | * like the old config system, uses numeric group IDs in the 53 | * config file, in order to speed up the lookup--the numeric ID is 54 | * the primary key and we save having to look up the group by 55 | * name.) 56 | */ 57 | public function inGroup ($userID, $groupID) { 58 | return null; // IMPLEMENT FOR YOUR CONTEXT 59 | } 60 | 61 | /** 62 | * Is the current user an admin? 63 | * 64 | * @param $userID the id of the relevant user, either the 65 | * currently logged in user or some other user. 66 | */ 67 | public function isAdmin ($userID) { 68 | return false; // IMPLEMENT FOR YOUR CONTEXT 69 | } 70 | 71 | /** 72 | * Is this an internal request? 73 | */ 74 | public function isInternalRequest () { 75 | return false; // IMPLEMENT FOR YOUR CONTEXT 76 | } 77 | 78 | /* 79 | * 'features' query param for url overrides. 80 | */ 81 | public function urlFeatures () { 82 | return array_key_exists('features', $_GET) ? $_GET['features'] : ''; 83 | } 84 | 85 | /* 86 | * Produce a random number in [0, 1) for RANDOM bucketing. 87 | */ 88 | public function random () { 89 | return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); 90 | } 91 | 92 | /* 93 | * Produce a randomish number in [0, 1) based on the given id. 94 | */ 95 | public function hash ($id) { 96 | return self::mapHex(hash('sha256', $id)); 97 | } 98 | 99 | /* 100 | * Record that $variant has been selected for feature named $name 101 | * by $selector and pass the same information along to the logger. 102 | */ 103 | public function log ($name, $variant, $selector) { 104 | $this->_selections[] = array($name, $variant, $selector); 105 | $this->_logger->log($name, $variant, $selector); 106 | } 107 | 108 | /* 109 | * Get the list of selections that we have recorded. The public 110 | * API for getting at the selections is Feature::selections which 111 | * should be the only caller of this method. 112 | */ 113 | public function selections () { 114 | return $this->_selections; 115 | } 116 | 117 | /** 118 | * Map a hex value to the half-open interval [0, 1) while 119 | * preserving uniformity of the input distribution. 120 | * 121 | * @param string $hex a hex string 122 | * @return float 123 | */ 124 | private static function mapHex($hex) { 125 | $len = min(40, strlen($hex)); 126 | $vMax = 1 << $len; 127 | $v = 0; 128 | for ($i = 0; $i < $len; $i++) { 129 | $bit = hexdec($hex[$i]) < 8 ? 0 : 1; 130 | $v = ($v << 1) + $bit; 131 | } 132 | $w = $v / $vMax; 133 | return $w; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /GENERALIZING.md: -------------------------------------------------------------------------------- 1 | # A theory about generalizing this code 2 | 3 | This code was written at Etsy to meet our specific needs and with a 4 | strong goal of making the code as simple as possible to understand. 5 | Which means that there are places where stuff is hardwired because we 6 | didn’t need the flexibility to do things another way. 7 | 8 | Obviously, some of the concepts embedded in this code are not going to 9 | be applicable outside the Etsy context. Thus if you want to use this 10 | code in a different context you have two choices: fork and hack or 11 | generalize. I’d actually suggest you start with the former. Hopefully 12 | things are structured well enough that if you rip out the 13 | Etsy-specific code you’ll be left with a few obvious holes to fill in 14 | with your own stuff. I’ve started things down that path for you by 15 | turning several methods into no-ops and marking with the comment 16 | “IMPLEMENT FOR YOUR CONTEXT”. 17 | 18 | However even with those bits ripped out, the structure of things in 19 | `master` is still tied to its Etsy heritage so, I've also made a quick 20 | start at that in the `generalized` branch. Note that the code in this 21 | branch is completely untested and may still be wrongheaded in many 22 | ways. (There are plans at Etsy to finish up this work and the port it 23 | back into our own codebase.) 24 | 25 | The basic approach I took in that branch was to introduce a new 26 | abstraction, the "experimental unit". Every feature is tested relative 27 | to some kind of experimental unit which is named in the feature's 28 | configuration (under the `unit` key) though the Feature_World 29 | implementation can provide a default. Each kind of experimental unit 30 | can support: 31 | 32 | - explicit configuration of variants based on some characteristic of 33 | the unit. 34 | 35 | - different bucketing schemes. 36 | 37 | As an example, the `Feature_EtsyRequestUnit` class, implements an 38 | experimental unit that maps to a web request. Each web request (in the 39 | Etsy context) has some information about the user who made the request 40 | (at least a cookie called the UAID and possibly an Etsy user ID if 41 | they are signed in). Additionally the request itself may have included 42 | a `features` query param that specifies specific variants for specific 43 | features and may also be an "internal" request, coming from someone 44 | within Etsy. 45 | 46 | The configuration syntax for a feature configured with this 47 | experimental unit (which is the default in the current implementation 48 | of `Feature_World`) can be configured with `users`, `groups`, `admin`, 49 | and `internal` keys, that specify variants to be assigned to specific 50 | users, users in specific groups, all Etsy employees (called "admin"), 51 | or for internal requests. 52 | 53 | This experimental unit also supports three bucketing styles: 'uaid', 54 | 'user', and 'random'. The 'uaid' style uses the cookie that is set on 55 | every request as the bucketing ID while the 'user' style uses the user 56 | ID of signed in users. Random bucketing, which assigns a variant 57 | randomly on each request, is only used for operational ramupus without 58 | user-visible effects such as switching from one backend database to 59 | another. 60 | 61 | When a call is made to `Feature::isEnabled` or `Feature::variant`, the 62 | experimental unit is responsible for saying whether a specific variant 63 | should be used (via the `assignedVariant` method) and, if not, what id 64 | should be used for bucketing the experimental unit into a variant (via 65 | `bucketingID`). 66 | 67 | In the generalized branch, both those methods take a second argument, 68 | `$data`, which is passed along to the `assignedVariant` and 69 | `bucketingID` methods. In general, implementations of these methods 70 | should ensure that any data they are passed is of the appropriate 71 | type: there is an obligation on callers of the Feature API methods to 72 | pass the appropriate kind of date for the kind of experimental unit 73 | the feature has been configured with. 74 | 75 | One thing this generalization does is get rid of the need for the 76 | `isEnabledFor`/`variantFor` and 77 | `isEnabledBucketingBy`/`variantBucketingBy` methods. The main use 78 | case, at Etsy, for the former pair is if we wanted to run an 79 | experiment where insted of bucketing by the user making a request, we 80 | want to bucket by the user who owns the shop the user is looking at. 81 | In the current API, that is achieved by passing the user object 82 | representing the shop owner to `isEnabledFor` and `variantFor`. And 83 | the use case for the `bucketingBy` methods is when we want to bucket 84 | on something that doesn't necessarily have an associated user. For 85 | instance if we wanted to run an experiment on a random selection of 86 | searches, we might use the search terms as the second argument to the 87 | `bucketingBy` methods. 88 | 89 | In the generalized API, in the first case we would instead configure a 90 | feature with a `unit` of, say, 'seller' that would map to a class that 91 | either expects some object reperesenting the seller to be passed to 92 | the Feature API calls or which knows how to figure out the seller from 93 | the context of the request. And in the second case we would configure 94 | a query with a `unit` of 'query' that maps to a class that expects a 95 | query string passed as the `$data` argument to the Feature methods. 96 | With such a class, we could then allow the feature to be configured to 97 | return a specific variant for specific queries, e.g. if we want to 98 | ensure that certain very popular queries are kept out of the treatment 99 | group for whatever reason. 100 | 101 | -Peter Seibel -------------------------------------------------------------------------------- /phpunit/Feature/WorldTest.php: -------------------------------------------------------------------------------- 1 | uaid = UAIDCookie::getSecureCookie(); 17 | $this->assertNotNull($this->uaid); 18 | 19 | $logger = $this->getMock('Logger', array('log')); 20 | $this->world = new Feature_World($logger); 21 | $this->user_id = 991; 22 | 23 | $this->setLoggedUserId(null); 24 | $this->assertNull(Std::loggedUser()); 25 | } 26 | 27 | function testIsAdminWithBlankUAIDCookie() { 28 | $this->setLoggedUserId($this->user_id); 29 | 30 | $this->assertFalse($this->world->isAdmin($this->user_id)); 31 | } 32 | 33 | function testIsAdminWithValidNonAdminUserUAIDCookie() { 34 | $this->setLoggedUserId($this->user_id); 35 | $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); 36 | 37 | $this->assertFalse($this->world->isAdmin($this->user_id)); 38 | } 39 | 40 | function testIsAdminWithValidAdminUAIDCookie() { 41 | $this->setLoggedUserId($this->user_id); 42 | $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); 43 | $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); 44 | 45 | $this->assertTrue($this->world->isAdmin($this->user_id)); 46 | } 47 | 48 | function testIsAdminWithNonLoggedInAdminAndValidAdminUAIDCookie() { 49 | $this->setLoggedUserId(null); 50 | $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); 51 | $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); 52 | 53 | $this->assertFalse($this->world->isAdmin($this->user_id)); 54 | } 55 | 56 | function testIsAdminWithLoggedInAdminUserAndBlankUAIDCookie() { 57 | $user = $this->adminUser(); 58 | $this->setLoggedUserId($user->user_id); 59 | 60 | $this->assertTrue($this->world->isAdmin($user->user_id)); 61 | } 62 | 63 | function testIsAdminWithLoggedInNonAdminUserAndBlankUAIDCookie() { 64 | $user = $this->nonAdminUser(); 65 | $this->setLoggedUserId($user->user_id); 66 | 67 | $this->assertFalse($this->world->isAdmin($user->user_id)); 68 | } 69 | 70 | function testIsAdminWithNonLoggedInAdminUserAndBlankUAIDCookie() { 71 | $user = $this->adminUser(); 72 | $this->setLoggedUserId(null); 73 | 74 | $this->assertTrue($this->world->isAdmin($user->user_id)); 75 | } 76 | 77 | function testIsAdminWithNonLoggedInNonAdminUserAndBlankUAIDCookie() { 78 | $user = $this->nonAdminUser(); 79 | $this->setLoggedUserId(null); 80 | 81 | $this->assertFalse($this->world->isAdmin($user->user_id)); 82 | } 83 | 84 | function testAtlasWorld() { 85 | $user = $this->atlasUser(); 86 | $this->setLoggedUserId($user->id); 87 | $this->setAtlasRequest(true); 88 | 89 | $this->assertFalse($this->world->isAdmin($user->id)); 90 | $this->assertFalse($this->world->inGroup($user->id, 1)); 91 | $this->assertEquals($user->id, $this->world->userID()); 92 | 93 | $this->setAtlasRequest(false); 94 | } 95 | 96 | function testHash() { 97 | $this->assertInternalType('float', $this->world->hash('somevalue')); 98 | 99 | $this->assertEquals( 100 | $this->world->hash('somevalue'), 101 | $this->world->hash('somevalue'), 102 | 'ensure return value is consistent' 103 | ); 104 | 105 | $this->assertGreaterThanOrEqual(0, $this->world->hash('somevalue')); 106 | $this->assertLessThan(1, $this->world->hash('somevalue')); 107 | } 108 | 109 | protected function getDatabaseConfigs() { 110 | $index_yml = dirname(__FILE__) . '/data/world/etsy_index.yml'; 111 | if (!file_exists($index_yml)) { 112 | throw new Exception($index_yml . ' does not exist'); 113 | } 114 | $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); 115 | $etsy_index = $builder 116 | ->connection(Testing_EtsyORM_Connections::ETSY_INDEX()) 117 | ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($index_yml)) 118 | ->build(); 119 | 120 | $aux_yml = dirname(__FILE__) . '/data/world/etsy_aux.yml'; 121 | if (!file_exists($aux_yml)) { 122 | throw new Exception($aux_yml . ' does not exist'); 123 | } 124 | $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); 125 | $etsy_aux = $builder 126 | ->connection(Testing_EtsyORM_Connections::ETSY_AUX()) 127 | ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($aux_yml)) 128 | ->build(); 129 | 130 | return array($etsy_index, $etsy_aux); 131 | } 132 | 133 | private function nonAdminUser() { 134 | return EtsyORM::getFinder('User')->find(1); 135 | } 136 | 137 | private function adminUser() { 138 | return EtsyORM::getFinder('User')->find(2); 139 | } 140 | 141 | private function atlasUser() { 142 | return EtsyORM::getFinder('Staff')->find(3); 143 | } 144 | 145 | private function setAtlasRequest($is_atlas) { 146 | $_SERVER["atlas_request"] = $is_atlas ? 1 : 0; 147 | } 148 | 149 | private function setLoggedUserId($user_id) { 150 | //Std::loggedUser() uses this global 151 | $GLOBALS['cookie_user_id'] = $user_id; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Feature/JSON.php: -------------------------------------------------------------------------------- 1 | (int)$value); 39 | } else if (is_string($value)) { 40 | $value = array('enabled' => $value); 41 | } 42 | 43 | $enabled = Feature_Util::arrayGet($value, 'enabled', 0); 44 | $users = self::expandUsersOrGroups(Feature_Util::arrayGet($value, 'users', array())); 45 | $groups = self::expandUsersOrGroups(Feature_Util::arrayGet($value, 'groups', array())); 46 | 47 | if ($enabled === 'off') { 48 | $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', 0, $users, $groups); 49 | $internal_url = false; 50 | } else if (is_numeric($enabled)) { 51 | $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', (int)$enabled, $users, $groups); 52 | } else if (is_string($enabled)) { 53 | $spec['variants'][] = self::makeVariantWithUsersAndGroups($enabled, 100, $users, $groups); 54 | $internal_url = false; 55 | } else if (is_array($enabled)) { 56 | foreach ($enabled as $v => $p) { 57 | if (is_numeric($p)) { 58 | // Kind of a kludge. $p had better be numeric and 59 | // there have been configs deployed where it 60 | // wasn't which breaks the Catapult config history 61 | // scripts. This will just skip those. 62 | $spec['variants'][] = self::makeVariantWithUsersAndGroups($v, $p, $users, $groups); 63 | } 64 | } 65 | } 66 | $spec['internal_url_override'] = $internal_url; 67 | 68 | if (array_key_exists('admin', $value)) { 69 | $spec['admin'] = $value['admin']; 70 | } 71 | if (array_key_exists('internal', $value)) { 72 | $spec['internal'] = $value['internal']; 73 | } 74 | if (array_key_exists('bucketing', $value)) { 75 | $spec['bucketing'] = $value['bucketing']; 76 | } 77 | if (array_key_exists('internal', $value)) { 78 | $spec['internal'] = $value['internal']; 79 | } 80 | if (array_key_exists('public_url_override', $value)) { 81 | $spec['public_url_override'] = $value['public_url_override']; 82 | } 83 | 84 | return $spec; 85 | } 86 | 87 | private static function makeSpec ($key) { 88 | return array( 89 | 'key' => $key, 90 | 'internal_url_override' => false, 91 | 'public_url_override' => false, 92 | 'bucketing' => 'uaid', 93 | 'admin' => null, 94 | 'internal' => null, 95 | 'variants' => array()); 96 | } 97 | 98 | private static function makeVariant ($name, $percentage) { 99 | return array( 100 | 'name' => $name, 101 | 'percentage' => $percentage, 102 | 'users' => array(), 103 | 'groups' => array()); 104 | } 105 | 106 | private static function makeVariantWithUsersAndGroups ($name, $percentage, $users, $groups) { 107 | return array( 108 | 'name' => $name, 109 | 'percentage' => $percentage, 110 | 'users' => self::extractForVariant($users, $name), 111 | 'groups' => self::extractForVariant($groups, $name), 112 | ); 113 | } 114 | 115 | private static function extractForVariant ($usersOrGroups, $name) { 116 | $result = array(); 117 | foreach ($usersOrGroups as $thing => $variant) { 118 | if ($variant == $name) { 119 | $result[] = $thing; 120 | } 121 | } 122 | return $result; 123 | } 124 | 125 | // This is based on parseUsersOrGroups in Feature_Config. Probably 126 | // this logic should be put in that class in a form that we can 127 | // use. 128 | private static function expandUsersOrGroups ($value) { 129 | if (is_string($value) || is_numeric($value)) { 130 | return array($value => Feature_Config::ON); 131 | 132 | } elseif (self::isList($value)) { 133 | $result = array(); 134 | foreach ($value as $who) { 135 | $result[$who] = Feature_Config::ON; 136 | } 137 | return $result; 138 | 139 | } elseif (is_array($value)) { 140 | $result = array(); 141 | foreach ($value as $variant => $whos) { 142 | foreach (self::asArray($whos) as $who) { 143 | $result[$who] = $variant; 144 | } 145 | } 146 | return $result; 147 | 148 | } else { 149 | return array(); 150 | } 151 | } 152 | 153 | private static function isList($a) { 154 | return is_array($a) and array_keys($a) === range(0, count($a) - 1); 155 | } 156 | 157 | private static function asArray ($x) { 158 | return is_array($x) ? $x : array($x); 159 | } 160 | 161 | } -------------------------------------------------------------------------------- /Feature/Lint.php: -------------------------------------------------------------------------------- 1 | 100. 13 | */ 14 | class Feature_Lint { 15 | 16 | private $_checked; 17 | private $_errors; 18 | private $_path; 19 | 20 | public function __construct() { 21 | $this->_checked = 0; 22 | $this->_errors = array(); 23 | $this->_path = array(); 24 | $this->syntax_keys = array( 25 | Feature_Config::ENABLED, 26 | Feature_Config::USERS, 27 | Feature_Config::GROUPS, 28 | Feature_Config::ADMIN, 29 | Feature_Config::INTERNAL, 30 | Feature_Config::PUBLIC_URL_OVERRIDE, 31 | Feature_Config::BUCKETING, 32 | 'data', 33 | ); 34 | 35 | $this->_legal_bucketing_values = array( 36 | Feature_Config::UAID, 37 | Feature_Config::USER, 38 | Feature_Config::RANDOM, 39 | ); 40 | } 41 | 42 | public function run($file = null) { 43 | $config = $this->fromFile($file); 44 | $this->assert($config, "*** Bad configuration."); 45 | $this->lintNested($config); 46 | } 47 | 48 | public function checked() { 49 | return $this->_checked; 50 | } 51 | 52 | public function errors() { 53 | return $this->_errors; 54 | } 55 | 56 | private function fromFile($file) { 57 | global $server_config; 58 | $content = file_get_contents($file); 59 | error_reporting(0); 60 | $r = eval('?>' . $content); 61 | error_reporting(-1); 62 | if ($r === null) { 63 | return $server_config; 64 | } else if ($r === false) { 65 | return false; 66 | } else { 67 | Logger::error("Wut? $r"); 68 | return false; 69 | } 70 | } 71 | 72 | /* 73 | * Recursively check nested feature configurations. Skips any keys 74 | * that have a syntactic meaning which includes 'data'. 75 | */ 76 | private function lintNested($config) { 77 | foreach ($config as $name => $stanza) { 78 | if (!in_array($name, $this->syntax_keys)) { 79 | $this->lint($name, $stanza); 80 | } 81 | } 82 | } 83 | 84 | private function lint($name, $stanza) { 85 | array_push($this->_path, $name); 86 | $this->_checked += 1; 87 | if (is_array($stanza)) { 88 | $this->checkForOldstyle($stanza); 89 | $this->checkEnabled($stanza); 90 | $this->checkUsers($stanza); 91 | $this->checkGroups($stanza); 92 | $this->checkAdmin($stanza); 93 | $this->checkInternal($stanza); 94 | $this->checkPublicURLOverride($stanza); 95 | $this->checkBucketing($stanza); 96 | $this->lintNested($stanza); 97 | } else { 98 | $this->assert(is_string($stanza), "Bad stanza: $stanza."); 99 | } 100 | array_pop($this->_path); 101 | } 102 | 103 | private function assert($ok, $message) { 104 | if (!$ok) { 105 | $loc = "[" . implode('.', $this->_path) . "]"; 106 | array_push($this->_errors, "$loc $message"); 107 | } 108 | } 109 | 110 | private function checkForOldstyle($stanza) { 111 | $enabled = Feature_Util::arrayGet($stanza, Feature_Config::ENABLED, 0); 112 | $rampup = Feature_Util::arrayGet($stanza, 'rampup', null); 113 | $this->assert($enabled !== 'rampup' || !$rampup, "Old-style config syntax detected."); 114 | } 115 | 116 | // 'enabled' must be a string, a number in [0,100], or an array of 117 | // (string => ints) such that the ints are all in [0,100] and the 118 | // total is <= 100. 119 | private function checkEnabled($stanza) { 120 | if (array_key_exists(Feature_Config::ENABLED, $stanza)) { 121 | $enabled = $stanza[Feature_Config::ENABLED]; 122 | if (is_numeric($enabled)) { 123 | $this->assert($enabled >= 0, Feature_Config::ENABLED . " too small: $enabled"); 124 | $this->assert($enabled <= 100, Feature_Config::ENABLED . "too big: $enabled"); 125 | } else if (is_array($enabled)) { 126 | $tot = 0; 127 | foreach ($enabled as $k => $v) { 128 | $this->assert(is_string($k), "Bad key $k in $enabled"); 129 | $this->assert(is_numeric($v), "Bad value $v for $k in $enabled"); 130 | $this->assert($v >= 0, "Bad value $v (too small) for $k"); 131 | $this->assert($v <= 100, "Bad value $v (too big) for $k"); 132 | if (is_numeric($v)) { 133 | $tot += $v; 134 | } 135 | } 136 | $this->assert($tot >= 0, "Bad total $tot (too small)"); 137 | $this->assert($tot <= 100, "Bad total $tot (too big)"); 138 | } 139 | } 140 | } 141 | 142 | private function checkUsers($stanza) { 143 | if (array_key_exists(Feature_Config::USERS, $stanza)) { 144 | $users = $stanza[Feature_Config::USERS]; 145 | if (is_array($users) && !self::isList($users)) { 146 | foreach ($users as $variant => $value) { 147 | $this->assert(is_string($variant), "User variant names must be strings."); 148 | $this->checkUserValue($value); 149 | } 150 | } else { 151 | $this->checkUserValue($users); 152 | } 153 | } 154 | } 155 | 156 | private function checkUserValue($users) { 157 | $this->assert(is_string($users) || self::isList($users), Feature_Config::USERS . " must be string or list of strings: '$users'"); 158 | if (self::isList($users)) { 159 | foreach ($users as $user) { 160 | $this->assert(is_string($user), Feature_Config::USERS . " elements must be strings: '$user'"); 161 | } 162 | } 163 | } 164 | 165 | private function checkGroups($stanza) { 166 | if (array_key_exists(Feature_Config::GROUPS, $stanza)) { 167 | $groups = $stanza[Feature_Config::GROUPS]; 168 | if (is_array($groups) && !self::isList($groups)) { 169 | foreach ($groups as $variant => $value) { 170 | $this->assert(is_string($variant), "Group variant names must be strings."); 171 | $this->checkGroupValue($value); 172 | } 173 | } else { 174 | $this->checkGroupValue($groups); 175 | } 176 | } 177 | } 178 | 179 | private function checkGroupValue($groups) { 180 | $this->assert(is_numeric($groups) || self::isList($groups), Feature_Config::GROUPS . " must be number or list of numbers"); 181 | if (self::isList($groups)) { 182 | foreach ($groups as $group) { 183 | $this->assert(is_numeric($group), Feature_Config::GROUPS . " elements must be numbers: '$group'"); 184 | } 185 | } 186 | } 187 | 188 | 189 | private function checkAdmin($stanza) { 190 | if (array_key_exists(Feature_Config::ADMIN, $stanza)) { 191 | $admin = $stanza[Feature_Config::ADMIN]; 192 | $this->assert(is_string($admin), "Admin must be string naming variant: '$admin'"); 193 | } 194 | } 195 | 196 | private function checkInternal($stanza) { 197 | if (array_key_exists(Feature_Config::INTERNAL, $stanza)) { 198 | $internal = $stanza[Feature_Config::INTERNAL]; 199 | $this->assert(is_string($internal), "Internal must be string naming variant: '$internal'"); 200 | } 201 | } 202 | 203 | private function checkPublicURLOverride($stanza) { 204 | if (array_key_exists(Feature_Config::PUBLIC_URL_OVERRIDE, $stanza)) { 205 | $public_url_override = $stanza[Feature_Config::PUBLIC_URL_OVERRIDE]; 206 | $this->assert(is_bool($public_url_override), "public_url_override must be a boolean: '$public_url_override'"); 207 | if (is_bool($public_url_override)) { 208 | $this->assert($public_url_override === true, "Gratuitous public_url_override (defaults to false)"); 209 | } 210 | } 211 | } 212 | 213 | private function checkBucketing($stanza) { 214 | if (array_key_exists(Feature_Config::BUCKETING, $stanza)) { 215 | $bucketing = $stanza[Feature_Config::BUCKETING]; 216 | $this->assert(is_string($bucketing), "Non-string bucketing: '$bucketing'"); 217 | $this->assert(in_array($bucketing, $this->_legal_bucketing_values), "Illegal bucketing: '$bucketing'"); 218 | } 219 | } 220 | 221 | private static function isList($a) { 222 | return is_array($a) and array_keys($a) === range(0, count($a) - 1); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /Feature.php: -------------------------------------------------------------------------------- 1 | isEnabled(); 56 | } 57 | 58 | /** 59 | * Test whether the named feature is enabled for a given 60 | * user. This method should only be used when we want to bucket 61 | * based on a user other than the current logged in user, e.g. if 62 | * we are bucketing different listings based on their owner. 63 | * 64 | * @static 65 | * @param string $name the config key for this feature. 66 | * 67 | * @param $user A user object whose id will be combined with $name 68 | * and hashed to get the bucketing. 69 | * 70 | * @return bool 71 | */ 72 | public static function isEnabledFor($name, $user) { 73 | return self::fromConfig($name)->isEnabledFor($user); 74 | } 75 | 76 | /** 77 | * Test whether the named feature is enabled for a given 78 | * arbitrary string. This method should only be used when we want to bucket 79 | * based on something other than a user, e.g. shops, teams, treasuries, tags, etc. 80 | * 81 | * @static 82 | * @param string $name the config key for this feature. 83 | * 84 | * @param $string A string which will be combined with $name 85 | * and hashed to get the bucketing. 86 | * 87 | * @return bool 88 | */ 89 | public static function isEnabledBucketingBy($name, $string) { 90 | return self::fromConfig($name)->isEnabledBucketingBy($string); 91 | } 92 | 93 | /** 94 | * Get the name of the A/B variant for the named feature for the 95 | * current user. Logs an error if called when isEnabled($name) 96 | * doesn't return true. (I.e. calls to this method should only 97 | * occur in blocks guarded by an isEnabled check.) 98 | * 99 | * Also logs an error if 'enabled' is 'on' for the named feature 100 | * since there should be no variant-dependent code left when a 101 | * feature has been fully enabled. To clean up a finished 102 | * experiment, first set 'enabled' to the name of the winning 103 | * variant. 104 | * 105 | * @static 106 | * @param string $name the config key for the feature. 107 | */ 108 | public static function variant($name) { 109 | return self::fromConfig($name)->variant(); 110 | } 111 | 112 | /** 113 | * Get the name of the A/B variant for the named feature for the 114 | * given user. This method should only be used when we want to 115 | * bucket based on a user other than the current logged in user, 116 | * e.g. if we are bucketing different listings based on their 117 | * owner. 118 | * 119 | * Logs an error if called when isEnabledFor($name, $user) doesn't 120 | * return true. (I.e. calls to this method should only occur in 121 | * blocks guarded by an isEnabledFor check.) 122 | 123 | * Also logs an error if 'enabled' is 'on' for the named feature 124 | * since there should be no variant-dependent code left when a 125 | * feature has been fully enabled. To clean up a finished 126 | * experiment, first set 'enabled' to the name of the winning 127 | * variant. 128 | * 129 | * @static 130 | * 131 | * @param string $name the config key for the feature. 132 | * 133 | * @param $user A user object whose id will be combined with $name 134 | * and hashed to get the bucketing. 135 | */ 136 | public static function variantFor($name, $user) { 137 | return self::fromConfig($name)->variantFor($user); 138 | } 139 | 140 | /** 141 | * Get the name of the A/B variant for the named feature, 142 | * bucketing by the given bucketing ID. (For other checks such as 143 | * admin, and user whitelists uses the current user which may or 144 | * may not make sense. If it doesn't make sense, don't configure 145 | * the feature to use those mechanisms.) Logs an error if called 146 | * when isEnabled($name) doesn't return true. (I.e. calls to this 147 | * method should only occur in blocks guarded by an isEnabled 148 | * check.) 149 | * 150 | * Also logs an error if 'enabled' is 'on' for the named feature 151 | * since there should be no variant-dependent code left when a 152 | * feature has been fully enabled. To clean up a finished 153 | * experiment, first set 'enabled' to the name of the winning 154 | * variant. 155 | * 156 | * @static 157 | * 158 | * @param string $name the config key for the feature. 159 | * 160 | * @param string $bucketingID A string to use as the bucketing ID. 161 | */ 162 | public static function variantBucketingBy($name, $bucketingID) { 163 | return self::fromConfig($name)->variantBucketingBy($bucketingID); 164 | } 165 | 166 | /* 167 | * Description of the feature. 168 | */ 169 | public static function description ($name) { 170 | return self::fromConfig($name)->description(); 171 | } 172 | 173 | /** 174 | * Get data related to a Feature name: config must be nested 175 | * under the Feature name, in an array key named 'data'. 176 | * @param string $name the Feature key to find data for 177 | * @param mixed $default what to return if not defined 178 | * 179 | * @return mixed 180 | */ 181 | public static function data($name, $default = array()) { 182 | return self::world()->configValue("$name.data", $default); 183 | } 184 | 185 | /** 186 | * Get data linked to a Feature name, specific for the enabled variant. 187 | * Nest data in an array named 'data' with a key for each variant. 188 | * @param string $name the Feature key to find data for 189 | * @param mixed $default what to return if not found 190 | * 191 | * @return mixed 192 | */ 193 | public static function variantData($name, $default = array()) { 194 | $data = self::data($name); 195 | $variant = self::variant($name); 196 | return isset($data[$variant]) ? $data[$variant] : $default; 197 | } 198 | 199 | /** 200 | * Get the named feature object. We cache the object after 201 | * building it from the config stanza to speed lookups. 202 | * 203 | * @static 204 | * 205 | * @param $name name of the feature. Used as a key into the global config array 206 | * 207 | * @return Feature_Config 208 | */ 209 | private static function fromConfig($name) { 210 | if (array_key_exists($name, self::$configCache)) { 211 | return self::$configCache[$name]; 212 | } else { 213 | $world = self::world(); 214 | $stanza = $world->configValue($name); 215 | return self::$configCache[$name] = new Feature_Config($name, $stanza, $world); 216 | } 217 | } 218 | 219 | /** 220 | * N.B. This method is for testing only. (The issue is that once a 221 | * Feature has been checked once, the result of the check is 222 | * cached but in tests we need to change the configuration and 223 | * have those changes be reflected in feature checks.) 224 | */ 225 | public static function clearCacheForTests() { 226 | self::$configCache = array(); 227 | } 228 | 229 | 230 | /** 231 | * Get the list of selections that have been made as an array of 232 | * (feature_name, variant_name, selector) arrays. This can be used 233 | * to record information about what features were associated with 234 | * what variants and why during the course of handling a request. 235 | */ 236 | public static function selections () { 237 | return self::world()->selections(); 238 | } 239 | 240 | /** 241 | * This API always uses the default World. Feature_Config takes 242 | * the world as an argument in order to ease unit testing. 243 | */ 244 | private static function world () { 245 | if (!isset(self::$defaultWorld)) { 246 | self::$defaultWorld = new Feature_World(new Feature_Logger()); 247 | } 248 | return self::$defaultWorld; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /phpunit/Feature/ConfigTest.php: -------------------------------------------------------------------------------- 1 | expectDisabled($c, array('uaid' => 0)); 20 | $this->expectDisabled($c, array('uaid' => 1)); 21 | } 22 | 23 | function testFullyEnabled() { 24 | $c = array('enabled' => 'on'); 25 | $this->expectEnabled($c, array('uaid' => '0')); 26 | $this->expectEnabled($c, array('uaid' => '1')); 27 | } 28 | 29 | function testSimpleDisabled () { 30 | $c = array('enabled' => 'off'); 31 | $this->expectDisabled($c, array('uaid' => '0')); 32 | $this->expectDisabled($c, array('uaid' => '1')); 33 | } 34 | 35 | function testVariantEnabled () { 36 | $c = array('enabled' => 'winner'); 37 | $this->expectEnabled($c, array('uaid' => '0'), 'winner'); 38 | $this->expectEnabled($c, array('uaid' => '1'), 'winner'); 39 | } 40 | 41 | function testFullyEnabledString() { 42 | $c = 'on'; 43 | $this->expectEnabled($c, array('uaid' => '0')); 44 | $this->expectEnabled($c, array('uaid' => '1')); 45 | } 46 | 47 | function testSimpleDisabledString () { 48 | $c = 'off'; 49 | $this->expectDisabled($c, array('uaid' => '0')); 50 | $this->expectDisabled($c, array('uaid' => '1')); 51 | } 52 | 53 | function testVariantEnabledString () { 54 | $c = 'winner'; 55 | $this->expectEnabled($c, array('uaid' => '0'), 'winner'); 56 | $this->expectEnabled($c, array('uaid' => '1'), 'winner'); 57 | } 58 | 59 | function testSimpleRampup () { 60 | $c = array('enabled' => '50'); 61 | $this->expectEnabled($c, array('uaid' => '0')); 62 | $this->expectEnabled($c, array('uaid' => '.1')); 63 | $this->expectEnabled($c, array('uaid' => '.4999')); 64 | $this->expectDisabled($c, array('uaid' => '.5')); 65 | $this->expectDisabled($c, array('uaid' => '.6')); 66 | $this->expectDisabled($c, array('uaid' => '.99')); 67 | $this->expectDisabled($c, array('uaid' => '1')); 68 | } 69 | 70 | function testMultivariant () { 71 | $c = array('enabled' => array('foo' => 2, 'bar' => 3)); 72 | $this->expectEnabled($c, array('uaid' => '0'), 'foo'); 73 | $this->expectEnabled($c, array('uaid' => '.01'), 'foo'); 74 | $this->expectEnabled($c, array('uaid' => '.01999'), 'foo'); 75 | $this->expectEnabled($c, array('uaid' => '.02'), 'bar'); 76 | $this->expectEnabled($c, array('uaid' => '.04999'), 'bar'); 77 | $this->expectDisabled($c, array('uaid' => '.05')); 78 | $this->expectDisabled($c, array('uaid' => '1')); 79 | } 80 | 81 | /* 82 | * Is feature disbaled by enabled => off despite every other 83 | * setting trying to turn it on? 84 | */ 85 | function testComplexDisabled () { 86 | $c = array( 87 | 'enabled' => 'off', 88 | 'users' => array('fred', 'sally'), 89 | 'groups' => array(1234, 2345), 90 | 'admin' => 'on', 91 | 'internal' => 'on', 92 | 'public_url_overrride' => true 93 | ); 94 | 95 | $this->expectDisabled($c, array('isInternal' => true, 'uaid' => '0')); 96 | $this->expectDisabled($c, array('userName' => 'fred', 'uaid' => '0')); 97 | $this->expectDisabled($c, array('inGroup' => array(0 => 1234), 'uaid' => '0')); 98 | $this->expectDisabled($c, array('uaid' => '100', 'uaid' => '0')); 99 | $this->expectDisabled($c, array('isAdmin' => true, 'uaid' => '0')); 100 | $this->expectDisabled($c, array('isInternal' => true, 'urlFeatures' => 'foo', 'uaid' => 0)); 101 | 102 | // Now all at once. 103 | $this->expectDisabled($c, array( 104 | 'isInternal' => true, 105 | 'userName' => 'fred', 106 | 'inGroup' => array(0 => 1234), 107 | 'uaid' => '100', 108 | 'isAdmin' => true, 109 | 'urlFeatures' => 'foo', 110 | 'userID' => '0')); 111 | } 112 | 113 | function testAdminOnly () { 114 | $c = array('enabled' => 0, 'admin' => 'on'); 115 | $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '0', 'userID' => '1')); 116 | $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '1', 'userID' => '1')); 117 | } 118 | 119 | function testAdminPlusSome () { 120 | $c = array('enabled' => 10, 'admin' => 'on'); 121 | $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '.5', 'userID' => '1')); 122 | $this->expectEnabled($c, array('isAdmin' => false, 'uaid' => '.05', 'userID' => '1')); 123 | $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '.5', 'userID' => '1')); 124 | } 125 | 126 | function testInternalOnly () { 127 | $c = array('enabled' => 0, 'internal' => 'on'); 128 | $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '0')); 129 | $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '1')); 130 | } 131 | 132 | function testInternalPlusSome () { 133 | $c = array('enabled' => 10, 'internal' => 'on'); 134 | $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '.5')); 135 | $this->expectEnabled($c, array('isInternal' => false, 'uaid' => '.05')); 136 | $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '.5')); 137 | } 138 | 139 | function testOneUser () { 140 | $c = array('enabled' => 0, 'users' => 'fred'); 141 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); 142 | $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); 143 | $this->expectDisabled($c, array('userID' => null, 'uaid' => 0)); 144 | } 145 | 146 | function testListOfOneUser () { 147 | $c = array('enabled' => 0, 'users' => array('fred')); 148 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); 149 | $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); 150 | } 151 | 152 | function testListOfUsers () { 153 | $c = array('enabled' => 0, 'users' => array('fred', 'ron')); 154 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); 155 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '1')); 156 | $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); 157 | } 158 | 159 | function testListOfUsersCaseInsensitive() { 160 | $c = array('enabled' => 0, 'users' => array('fred', 'FunGuy')); 161 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); 162 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FunGuy', 'userID' => '1')); 163 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FUNGUY', 'userID' => '1')); 164 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'funguy', 'userID' => '1')); 165 | } 166 | 167 | function testArrayOfUsers () { 168 | // It might be kind of nice to allow 'enabled' => 0 here but 169 | // then we lose the ability to check that the variants 170 | // mentioned in a users clause are actually valid 171 | // variants. Which maybe is okay: perhaps we'd like to be able 172 | // to enable variants for users that are otherwise disabled. 173 | $c = array('enabled' => array('twins' => 0, 'other' => 0), 174 | 'users' => array( 175 | 'twins' => array('fred', 'george'), 176 | 'other' => 'ron')); 177 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1'), 'twins'); 178 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '2'), 'twins'); 179 | $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '3'), 'other'); 180 | $this->expectDisabled($c, array('uaid' => '0', 'userName' => 'percy', 'userID' => '4')); 181 | } 182 | 183 | function testOneGroup () { 184 | $c = array('enabled' => 0, 'groups' => 1234); 185 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); 186 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); 187 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => null, 'uaid' => 0)); 188 | } 189 | 190 | function testListOfOneGroup () { 191 | $c = array('enabled' => 0, 'groups' => array(1234)); 192 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); 193 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); 194 | } 195 | 196 | function testListOfGroups () { 197 | $c = array('enabled' => 0, 'groups' => array(1234, 2345)); 198 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); 199 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); 200 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => 3, 'inGroup' => array(3 => array()))); 201 | } 202 | function testArrayOfGroups () { 203 | // See comment at testArrayOfUsers; similar issue applies here. 204 | $c = array('enabled' => array('twins' => 0, 'other' => 0), 205 | 'groups' => array( 206 | 'twins' => array(1234, 2345), 207 | 'other' => 3456)); 208 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234))), 'twins'); 209 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345))), 'twins'); 210 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => 3, 'inGroup' => array(3 => array(3456))), 'other'); 211 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => 4, 'inGroup' => array(4 => array()))); 212 | } 213 | 214 | function testUrlOverride () { 215 | $c = array('enabled' => 0); 216 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); 217 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); 218 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); 219 | $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); 220 | $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); 221 | $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar')); 222 | } 223 | 224 | function testPublicUrlOverride () { 225 | $c = array('enabled' => 0, 'public_url_override' => true); 226 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); 227 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); 228 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); 229 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); 230 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); 231 | $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar'), 'bar'); 232 | } 233 | 234 | function testBucketBy () { 235 | $c = array('enabled' => 2, 'bucketing' => 'user'); 236 | $this->expectEnabled($c, array('uaid' => 1, 'userID' => .01)); 237 | $this->expectDisabled($c, array('uaid' => 0, 'userID' => .03)); 238 | } 239 | 240 | function testUAIDFallback () { 241 | $c = array('enabled' => 2, 'bucketing' => 'user'); 242 | $this->expectEnabled($c, array('userID' => null, 'uaid' => .01)); 243 | $this->expectDisabled($c, array('userID' => null, 'uaid' => .03)); 244 | } 245 | 246 | /* 247 | * Ignore userID and uuaid in favor of random numbers for bucketing. 248 | */ 249 | function testRandom () { 250 | $c = array('enabled' => 3, 'bucketing' => 'random'); 251 | $this->expectEnabled($c, array('uaid' => 1, 'random' => .00)); 252 | $this->expectEnabled($c, array('uaid' => 1, 'random' => .01)); 253 | $this->expectEnabled($c, array('uaid' => 1, 'random' => .02)); 254 | $this->expectEnabled($c, array('uaid' => 1, 'random' => .02999)); 255 | $this->expectDisabled($c, array('uaid' => 0, 'random' => .03)); 256 | $this->expectDisabled($c, array('uaid' => 0, 'random' => .04)); 257 | $this->expectDisabled($c, array('uaid' => 0, 'random' => .99999)); 258 | } 259 | 260 | /* 261 | * Somewhat indirect test that we cache the value by id: even if 262 | * the config is set up to use a random bucket (i.e. indpendent of 263 | * the id) it should still return the same value for the same id 264 | * which we test by having the two 'random' values returned by the 265 | * test world be ones that would change the enabled status if they 266 | * were both used. 267 | */ 268 | function testRandomCached () { 269 | // Initially enabled 270 | $c = array('enabled' => 3, 'bucketing' => 'random'); 271 | $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => 0)); 272 | $config = new Feature_Config('foo', $c, $w); 273 | $this->assertTrue($config->isEnabled()); 274 | $w->nextRandomValue(.5); 275 | $this->assertTrue($config->isEnabled()); 276 | 277 | // Initially disabled 278 | $c = array('enabled' => 3, 'bucketing' => 'random'); 279 | $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => .5)); 280 | $config = new Feature_Config('foo', $c, $w); 281 | $this->assertFalse($config->isEnabled()); 282 | $w->nextRandomValue(0); 283 | $this->assertFalse($config->isEnabled()); 284 | } 285 | 286 | function testDescription () { 287 | // Default description. 288 | $c = array('enabled' => 'on'); 289 | $w = new Testing_Feature_MockWorld(array()); 290 | $config = new Feature_Config('foo', $c, $w); 291 | $this->assertNotNull($config->description()); 292 | 293 | // Provided description. 294 | $c = array('enabled' => 'on', 'description' => 'The description.'); 295 | $w = new Testing_Feature_MockWorld(array()); 296 | $config = new Feature_Config('foo', $c, $w); 297 | $this->assertEquals($config->description(), 'The description.'); 298 | } 299 | 300 | function testIsEnabledForAcceptsREST_User() { 301 | //we don't want to test the implementation of user bucketing here, just the public API 302 | $user_id = 1; 303 | $user = $this->getMock('REST_User'); 304 | $user->expects($this->once()) 305 | ->method('getUserId') 306 | ->will($this->returnValue($user_id)); 307 | $config = new Feature_Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); 308 | $this->assertFalse($config->isEnabledFor($user)); 309 | } 310 | 311 | function testIsEnabledForAcceptsEtsyModel_User() { 312 | //we don't want to test the implementation of user bucketing here, just the public API 313 | $user = new EtsyModel_User(); 314 | $user->user_id = 1; 315 | $config = new Feature_Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); 316 | $this->assertFalse($config->isEnabledFor($user)); 317 | } 318 | 319 | 320 | //////////////////////////////////////////////////////////////////////// 321 | // Test helper methods. 322 | 323 | /* 324 | * Given a config stanza and a world configuration, we expect that 325 | * isEnabled() will return true and that variant will be a given 326 | * value (default 'on'). 327 | */ 328 | private function expectEnabled ($stanza, $world, $variant = 'on') { 329 | $config = new Feature_Config('foo', $stanza, new Testing_Feature_MockWorld($world)); 330 | $this->assertTrue($config->isEnabled()); 331 | $this->assertEquals($config->variant(), $variant); 332 | 333 | if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { 334 | unset($stanza['enabled']); 335 | $this->expectEnabled($stanza, $world, $variant); 336 | } 337 | } 338 | 339 | /* 340 | * Given a config stanza and a world configuration, we expect that 341 | * isEnabled() will return false. 342 | */ 343 | private function expectDisabled ($stanza, $world) { 344 | $config = new Feature_Config('foo', $stanza, new Testing_Feature_MockWorld($world)); 345 | $this->assertFalse($config->isEnabled()); 346 | if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { 347 | unset($stanza['enabled']); 348 | $this->expectDisabled($stanza, $world); 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /Feature/Config.php: -------------------------------------------------------------------------------- 1 | _name = $name; 50 | $this->_cache = array(); 51 | $this->_world = $world; 52 | 53 | // Special case to save some memory--if the value is just a 54 | // string that is the same as setting enabled to that variant 55 | // (typically 'on' or 'off' but possibly another variant 56 | // name). This reduces the number of array objects we have to 57 | // create when reading the config file. 58 | if (is_null($stanza)) { 59 | $stanza = array(self::ENABLED => self::OFF); 60 | } elseif (is_string($stanza)) { 61 | $stanza = array(self::ENABLED => $stanza); 62 | } 63 | 64 | // Pull stuff from the config stanza. 65 | $this->_description = $this->parseDescription($stanza); 66 | $this->_enabled = $this->parseEnabled($stanza); 67 | $this->_users = $this->parseUsersOrGroups($stanza, self::USERS); 68 | $this->_groups = $this->parseUsersOrGroups($stanza, self::GROUPS); 69 | $this->_adminVariant = $this->parseVariantName($stanza, self::ADMIN); 70 | $this->_internalVariant = $this->parseVariantName($stanza, self::INTERNAL); 71 | $this->_public_url_override = $this->parsePublicURLOverride($stanza); 72 | $this->_bucketing = $this->parseBucketBy($stanza); 73 | 74 | // Put the _enabled value into a more useful form for actually doing bucketing. 75 | $this->_percentages = $this->computePercentages(); 76 | } 77 | 78 | //////////////////////////////////////////////////////////////////////// 79 | // Public API, though note that Feature.php is the only code that 80 | // should be using this class directly. 81 | 82 | /* 83 | * Is this feature enabled for the default id and the logged in 84 | * user, if any? 85 | */ 86 | public function isEnabled () { 87 | $bucketingID = $this->bucketingID(); 88 | $userID = $this->_world->userID(); 89 | return $this->chooseVariant($bucketingID, $userID, false) !== self::OFF; 90 | } 91 | 92 | /* 93 | * What variant is enabled for the default id and the logged in 94 | * user, if any? 95 | */ 96 | public function variant () { 97 | $bucketingID = $this->bucketingID(); 98 | $userID = $this->_world->userID(); 99 | return $this->chooseVariant($bucketingID, $userID, true);; 100 | } 101 | 102 | /* 103 | * Is this feature enabled for the given user? 104 | */ 105 | public function isEnabledFor ($user) { 106 | $userID = $this->getUserIdFrom($user); 107 | return $this->chooseVariant($userID, $userID, false) !== self::OFF; 108 | } 109 | 110 | /* 111 | * Is this feature enabled, bucketing on the given bucketing 112 | * ID? (Other methods of enabling a feature and specifying a 113 | * variant such as users, groups, and query parameters, will still 114 | * work.) 115 | */ 116 | public function isEnabledBucketingBy ($bucketingID) { 117 | $userID = $this->_world->userID(); 118 | return $this->chooseVariant($bucketingID, $userID, false) !== self::OFF; 119 | } 120 | 121 | /* 122 | * What variant is enabled for the given user? 123 | */ 124 | public function variantFor ($user) { 125 | $userID = $this->getUserIdFrom($user); 126 | return $this->chooseVariant($userID, $userID, true); 127 | } 128 | 129 | /* 130 | * What variant is enabled, bucketing on the given bucketing ID, 131 | * if any? 132 | */ 133 | public function variantBucketingBy ($bucketingID) { 134 | $userID = $this->_world->userID(); 135 | return $this->chooseVariant($bucketingID, $userID, true);; 136 | } 137 | 138 | /* 139 | * Description of the feature. 140 | */ 141 | public function description () { 142 | return $this->_description; 143 | } 144 | 145 | 146 | //////////////////////////////////////////////////////////////////////// 147 | // Internals 148 | 149 | /* 150 | * Accept different user objects and return user_id 151 | */ 152 | private function getUserIdFrom($user) { 153 | if ($user instanceof REST_User) { 154 | // $user->user_id is protected so not accessible 155 | return $user->getUserId(); 156 | } 157 | return $user->user_id; 158 | } 159 | 160 | /* 161 | * Get the name of the variant we should use. Returns OFF if the 162 | * feature is not enabled for $id. When $inVariantMethod is 163 | * true will also check the conditions that should hold for a 164 | * correct call to variant or variantFor: they should not be 165 | * called for features that are completely enabled (i.e. 'enabled' 166 | * => 'on') since all such variant-specific code should have been 167 | * cleaned up before changing the config and they should not be 168 | * called if the feature is, in fact, disabled for the given id 169 | * since those two methods should always be guarded by an 170 | * isEnabled/isEnabledFor call. 171 | * 172 | * @param $bucketingID the id used to assign a variant based on 173 | * the percentage of users that should see different variants. 174 | * 175 | * @param $userID the identity of the user to be used for the 176 | * special 'admin', 'users', and 'groups' access checks. 177 | * 178 | * @param $inVariantMethod were we called from variant or 179 | * variantFor, in which case we want to perform some certain 180 | * sanity checks to make sure the code is being used correctly. 181 | */ 182 | private function chooseVariant ($bucketingID, $userID, $inVariantMethod) { 183 | if ($inVariantMethod && $this->_enabled === self::ON) { 184 | $this->error("Variant check when fully enabled"); 185 | } 186 | 187 | if (is_string($this->_enabled)) { 188 | // When enabled is on, off, or a variant name, that's the 189 | // end of the story. 190 | return $this->_enabled; 191 | } else { 192 | if (is_null($bucketingID)) { 193 | throw new InvalidArgumentException( 194 | "no bucketing ID supplied. if testing, configure feature " . 195 | "with enabled => 'on' or 'off', feature name = " . 196 | $this->_name 197 | ); 198 | } 199 | 200 | $bucketingID = (string)$bucketingID; 201 | if (array_key_exists($bucketingID, $this->_cache)) { 202 | // Note that this caching is not just an optimization: 203 | // it prevents us from double logging a single 204 | // feature--we only want to log each distinct checked 205 | // feature once. 206 | // 207 | // The caching also affects the semantics when we use 208 | // random bucketing (rather than hashing the id), i.e. 209 | // 'random' => 'true', by making the variant and 210 | // enabled status stable within a request. 211 | return $this->_cache[$bucketingID]; 212 | } else { 213 | list($v, $selector) = 214 | $this->variantFromURL($userID) ?: 215 | $this->variantForUser($userID) ?: 216 | $this->variantForGroup($userID) ?: 217 | $this->variantForAdmin($userID) ?: 218 | $this->variantForInternal() ?: 219 | $this->variantByPercentage($bucketingID) ?: 220 | array(self::OFF, 'w'); 221 | 222 | if ($inVariantMethod && $v === self::OFF) { 223 | $this->error("Variant check outside enabled check"); 224 | } 225 | 226 | $this->_world->log($this->_name, $v, $selector); 227 | 228 | return $this->_cache[$bucketingID] = $v; 229 | } 230 | } 231 | } 232 | 233 | /* 234 | * Return the globally accessible ID used by the one-arg isEnabled 235 | * and variant methods based on the feature's bucketing property. 236 | */ 237 | private function bucketingID () { 238 | switch ($this->_bucketing) { 239 | case self::UAID: 240 | case self::RANDOM: 241 | // In the RANDOM case we still need a bucketing id to keep 242 | // the assignment stable within a request. 243 | // Note that when being run from outside of a web request (e.g. crons), 244 | // there is no UAID, so we default to a static string 245 | $uaid = $this->_world->uaid(); 246 | return $uaid ? $uaid : "no uaid"; 247 | case self::USER: 248 | $userID = $this->_world->userID(); 249 | // Not clear if this is right. There's an argument to be 250 | // made that if we're bucketing by userID and the user is 251 | // not logged in we should treat the feature as disabled. 252 | return !is_null($userID) ? $userID : $this->_world->uaid(); 253 | default: 254 | throw new InvalidArgumentException("Bad bucketing: $this->bucketing"); 255 | } 256 | } 257 | 258 | /* 259 | * For internal requests or if the feature has public_url_override 260 | * set to true, a specific variant can be specified in the 261 | * 'features' query parameter. In all other cases return false, 262 | * meaning nothing was specified. Note that foo:off will turn off 263 | * the 'foo' feature. 264 | */ 265 | private function variantFromURL ($userID) { 266 | if ($this->_public_url_override or 267 | $this->_world->isInternalRequest() or 268 | $this->_world->isAdmin($userID) 269 | ) { 270 | $urlFeatures = $this->_world->urlFeatures(); 271 | if ($urlFeatures) { 272 | foreach (explode(',', $urlFeatures) as $f) { 273 | $parts = explode(':', $f); 274 | if ($parts[0] === $this->_name) { 275 | return array(isset($parts[1]) ? $parts[1] : self::ON, 'o'); 276 | } 277 | } 278 | } 279 | } 280 | return false; 281 | } 282 | 283 | /* 284 | * Get the variant this user should see, if one was configured, 285 | * false otherwise. 286 | */ 287 | private function variantForUser ($userID) { 288 | if ($this->_users) { 289 | $name = $this->_world->userName($userID); 290 | if ($name && array_key_exists($name, $this->_users)) { 291 | return array($this->_users[$name], 'u'); 292 | } 293 | } 294 | return false; 295 | } 296 | 297 | /* 298 | * Get the variant this user should see based on their group 299 | * memberships, if one was configured, false otherwise. N.B. If 300 | * the user is in multiple groups that are configured to see 301 | * different variants, they'll get the variant for one of their 302 | * groups but there's no saying which one. If this is a problem in 303 | * practice we could make the configuration more complex. Or you 304 | * can just provide a specific variant via the 'users' property. 305 | */ 306 | private function variantForGroup ($userID) { 307 | if ($userID) { 308 | foreach ($this->_groups as $groupID => $variant) { 309 | if ($this->_world->inGroup($userID, $groupID)) { 310 | return array($variant, 'g'); 311 | } 312 | } 313 | } 314 | return false; 315 | } 316 | 317 | /* 318 | * What variant, if any, should we return if the current user is 319 | * an admin. 320 | */ 321 | private function variantForAdmin ($userID) { 322 | if ($userID && $this->_adminVariant) { 323 | if ($this->_world->isAdmin($userID)) { 324 | return array($this->_adminVariant, 'a'); 325 | } 326 | } 327 | return false; 328 | } 329 | 330 | /* 331 | * What variant, if any, should we return for internal requests. 332 | */ 333 | private function variantForInternal () { 334 | if ($this->_internalVariant) { 335 | if ($this->_world->isInternalRequest()) { 336 | return array($this->_internalVariant, 'i'); 337 | } 338 | } 339 | return false; 340 | } 341 | 342 | /* 343 | * Finally, the normal case: use the percentage of users who 344 | * should see each variant to map a randomish number to a 345 | * particular variant. 346 | */ 347 | private function variantByPercentage ($id) { 348 | $n = 100 * $this->randomish($id); 349 | foreach ($this->_percentages as $v) { 350 | // === 100 check may not be necessary but I'm not good 351 | // enough numerical analyst to be sure. 352 | if ($n < $v[0] || $v[0] === 100) { 353 | return array($v[1], 'w'); 354 | } 355 | } 356 | return false; 357 | } 358 | 359 | /* 360 | * A randomish number in [0, 1) based on the feature name and $id 361 | * unless we are bucketing completely at random. 362 | */ 363 | private function randomish ($id) { 364 | return $this->_bucketing === self::RANDOM 365 | ? $this->_world->random() : $this->_world->hash($this->_name . '-' . $id); 366 | } 367 | 368 | //////////////////////////////////////////////////////////////////////// 369 | // Configuration parsing 370 | 371 | private function parseDescription ($stanza) { 372 | return Feature_Util::arrayGet($stanza, self::DESCRIPTION, 'No description.'); 373 | } 374 | 375 | /* 376 | * Parse the 'enabled' property of the feature's config stanza. 377 | */ 378 | private function parseEnabled ($stanza) { 379 | 380 | $enabled = Feature_Util::arrayGet($stanza, self::ENABLED, 0); 381 | 382 | if (is_numeric($enabled)) { 383 | if ($enabled < 0) { 384 | $this->error("enabled ($enabled) < 0"); 385 | $enabled = 0; 386 | } elseif ($enabled > 100) { 387 | $this->error("enabled ($enabled) > 100"); 388 | $enabled = 100; 389 | } 390 | return array('on' => $enabled); 391 | 392 | } elseif (is_string($enabled) or is_array($enabled)) { 393 | return $enabled; 394 | } else { 395 | $this->error("Malformed enabled property"); 396 | } 397 | } 398 | 399 | /* 400 | * Returns an array of pairs with the first element of the pair 401 | * being the upper-boundary of the variants percentage and the 402 | * second element being the name of the variant. 403 | */ 404 | private function computePercentages () { 405 | $total = 0; 406 | $percentages = array(); 407 | if (is_array($this->_enabled)) { 408 | foreach ($this->_enabled as $variant => $percentage) { 409 | if (!is_numeric($percentage) || $percentage < 0 || $percentage > 100) { 410 | $this->error("Bad percentage $percentage"); 411 | } 412 | if ($percentage > 0) { 413 | $total += $percentage; 414 | $percentages[] = array($total, $variant); 415 | } 416 | if ($total > 100) { 417 | $this->error("Total of percentages > 100: $total"); 418 | } 419 | } 420 | } 421 | return $percentages; 422 | } 423 | 424 | /* 425 | * Parse the value of the 'users' and 'groups' properties of the 426 | * feature's config stanza, returning an array mappinng the user 427 | * or group names to they variant they should see. 428 | */ 429 | private function parseUsersOrGroups ($stanza, $what) { 430 | $value = Feature_Util::arrayGet($stanza, $what); 431 | if (is_string($value) || is_numeric($value)) { 432 | // Users are configrued with their user names. Groups as 433 | // numeric ids. (Not sure if that's a great idea.) 434 | return array($value => self::ON); 435 | 436 | } elseif (self::isList($value)) { 437 | $result = array(); 438 | foreach ($value as $who) { 439 | $result[strtolower($who)] = self::ON; 440 | } 441 | return $result; 442 | 443 | } elseif (is_array($value)) { 444 | $result = array(); 445 | $bad_keys = is_array($this->_enabled) ? 446 | array_keys(array_diff_key($value, $this->_enabled)) : 447 | array(); 448 | if (!$bad_keys) { 449 | foreach ($value as $variant => $whos) { 450 | foreach (self::asArray($whos) as $who) { 451 | $result[strtolower($who)] = $variant; 452 | } 453 | } 454 | return $result; 455 | 456 | } else { 457 | $this->error("Unknown variants " . implode(', ', $bad_keys)); 458 | } 459 | } else { 460 | return array(); 461 | } 462 | } 463 | 464 | /* 465 | * Parse the variant name value for the 'admin' and 'internal' 466 | * properties. If non-falsy, must be one of the keys in the 467 | * enabled map unless enabled is 'on' or 'off'. 468 | */ 469 | private function parseVariantName ($stanza, $what) { 470 | $value = Feature_Util::arrayGet($stanza, $what); 471 | if ($value) { 472 | if (is_array($this->_enabled)) { 473 | if (array_key_exists($value, $this->_enabled)) { 474 | return $value; 475 | } else { 476 | $this->error("Unknown variant $value"); 477 | } 478 | } else { 479 | return $value; 480 | } 481 | } else { 482 | return false; 483 | } 484 | } 485 | 486 | private function parsePublicURLOverride ($stanza) { 487 | return Feature_Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false); 488 | } 489 | 490 | private function parseBucketBy ($stanza) { 491 | return Feature_Util::arrayGet($stanza, self::BUCKETING, self::UAID); 492 | } 493 | 494 | //////////////////////////////////////////////////////////////////////// 495 | // Genericish utilities 496 | 497 | /* 498 | * Is the given object an array value that could have been created 499 | * with array(...) with no =>'s in the ...? 500 | */ 501 | private static function isList($a) { 502 | return is_array($a) and array_keys($a) === range(0, count($a) - 1); 503 | } 504 | 505 | private static function asArray ($x) { 506 | return is_array($x) ? $x : array($x); 507 | } 508 | 509 | private function error ($message) { 510 | // IMPLEMENT FOR YOUR CONTEXT 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feature API 2 | 3 | Etsy's Feature flagging API used for operational rampups and A/B 4 | testing. 5 | 6 | The Feature API is how we selectively enable and disable features at a 7 | very fine grain as well as enabling features for a percentage of users 8 | for operational ramp-ups and for A/B tests. A feature can be 9 | completely enabled, completely disabled, or something in between and 10 | can comprise a number of related variants. 11 | 12 | For features that are not completely enabled or disabled, we log every 13 | time we check whether a feature is enabled and include the result, 14 | including what variant was selected, in the events we fire. 15 | 16 | The two main API entry points are: 17 | 18 | Feature::isEnabled('my_feature') 19 | 20 | which returns true when `my_feature` is enabled and, for multi-variant 21 | features: 22 | 23 | Feature::variant('my_feature') 24 | 25 | which returns the name of the particular variant which should be used. 26 | 27 | The single argument to each of these methods is the name of the 28 | feature to test. 29 | 30 | A typical use of `Feature::isEnabled` for a single-variant feature 31 | would look something like this: 32 | 33 | if (Feature::isEnabled('my_feature')) { 34 | // do stuff 35 | } 36 | 37 | For a multi-variant feature, within the block guarded by the 38 | `Feature::isEnabled` check, we can determine the appropriate code to 39 | run for each variant with something like this: 40 | 41 | if (Feature::isEnabled('my_feature')) { 42 | 43 | switch (Feature::variant('my_feature')) { 44 | case 'foo': 45 | // do stuff appropriate for the foo variant 46 | break; 47 | case 'bar': 48 | // do stuff appropriate for the bar variant 49 | break; 50 | } 51 | } 52 | 53 | It is an error (and will be logged as such) to ask for the variant of 54 | a feature that is not enabled. So the calls to variant should always 55 | be guarded by an `Feature::isEnabled` check. 56 | 57 | The API also provides two other pairs of methods that will be used 58 | much less frequently: 59 | 60 | Feature::isEnabledFor('my_feature', $user) 61 | 62 | Feature::variantFor('my_feature', $user) 63 | 64 | and 65 | 66 | Feature::isEnabledBucketingBy('my_feature', $bucketingID) 67 | 68 | Feature::variantBucketingBy('my_feature', $bucketingID) 69 | 70 | These methods exist only to support a couple very specific use-cases: 71 | when we want to enable or disable a feature based not on the user 72 | making the request but on some other user or when we want to bucket a 73 | percentage of executions based on something entirely other than a 74 | user.) The canonical case for the former, at Etsy, is if we wanted to 75 | change something about how we deal with listings and instead of 76 | enabling the feature for only some users but for all listings those 77 | users see, but instead we want to enable it for all users but for only 78 | some of the listings. Then we could use `isEnabledFor` and 79 | `variantFor` and pass in the user object representing the owner of the 80 | listing. That would also allow us to enable the feature for specific 81 | listing owners. The `bucketingBy` methods serve a similar purpose 82 | except when there either is no relevant user or where we don't want to 83 | always put the same user in the same bucket. Thus if we wanted to 84 | enable a certain feature for 10% of all listings displayed, 85 | independent of both the user making the request and the user who owned 86 | the listing, we could use `isEnabledBucketingBy` with the listing id 87 | as the bucketing ID. 88 | 89 | In general it is much more likely you want to use the plain old 90 | `isEnabled` and `variant` methods. 91 | 92 | For Smarty templates, where static methods can’t readily be called, 93 | there is an object, `$feature`, wired up in Tpl.php that exposes the 94 | same four methods as the Feature API but as instance methods, for 95 | instance: 96 | 97 | {% if $feature->isEnabled("my_feature") %} 98 | 99 | ## Configuration cookbook 100 | 101 | There are a number of common configurations so before I explain the 102 | complete syntax of the feature configuration stanzas, here are some of 103 | the more common cases along with the most concise way to write the 104 | configuration. 105 | 106 | ### A totally enabled feature: 107 | 108 | $server_config['foo'] = 'on'; 109 | 110 | ### A totally disabled feature: 111 | 112 | $server_config['foo'] = 'off'; 113 | 114 | ### Feature with winning variant turned on for everyone 115 | 116 | $server_config['foo'] = 'blue_background'; 117 | 118 | ### Feature enabled only for admins: 119 | 120 | $server_config['foo'] = array('admin' => 'on'); 121 | 122 | ### Single-variant feature ramped up to 1% of users. 123 | 124 | $server_config['foo'] = array('enabled' => 1); 125 | 126 | ### Multi-variant feature ramped up to 1% of users for each variant. 127 | 128 | $server_config['foo'] = array( 129 | 'enabled' => array( 130 | 'blue_background' => 1, 131 | 'orange_background' => 1, 132 | 'pink_background' => 1, 133 | ), 134 | ); 135 | 136 | ### Enabled for a single specific user. 137 | 138 | $server_config['foo'] = array('users' => 'fred'); 139 | 140 | ### Enabled for a few specific users. 141 | 142 | $server_config['foo'] = array( 143 | 'users' => array('fred', 'barney', 'wilma', 'betty'), 144 | ); 145 | 146 | ### Enabled for a specific group 147 | 148 | $server_config['foo'] = array('groups' => 1234); 149 | 150 | ### Enabled for 10% of regular users and all admin. 151 | 152 | $server_config['foo'] = array( 153 | 'enabled' => 10, 154 | 'admin' => 'on', 155 | ); 156 | 157 | ### Feature ramped up to 1% of requests, bucketing at random rather than by user 158 | 159 | $server_config['foo'] = array( 160 | 'enabled' => 1, 161 | 'bucketing' => 'random', 162 | ); 163 | 164 | ### Single-variant feature in 50/50 A/B test 165 | 166 | $server_config['foo'] = array('enabled' => 50); 167 | 168 | ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). 169 | 170 | $server_config['foo'] = array( 171 | 'enabled' => array( 172 | 'blue_background' => 20, 173 | 'orange_background' => 20, 174 | 'pink_background' => 20, 175 | ), 176 | ); 177 | 178 | ### New feature intended only to be enabled by adding ?features=foo to a URL 179 | 180 | $server_config['foo'] = array('enabled' => 0); 181 | 182 | This is kind of a funny edge case. It could also be written: 183 | 184 | $server_config['foo'] = array(); 185 | 186 | since a missing `'enabled'` is defaulted to 0. 187 | 188 | ## Configuration details 189 | 190 | Each feature’s config stanza controls when the feature is enabled and 191 | what variant should be used when it is. 192 | 193 | Leaving aside a few shorthands that will be explained in a moment, the 194 | value of a feature config stanza is an array with a number of special 195 | keys, the most important of which is `'enabled'`. 196 | 197 | In its full form, the value of the `'enabled'` property is either the 198 | string `'off'`, meaning the feature is entirely disabled, any other 199 | string, meaning the named variant is enabled for all requests, or an 200 | array whose keys are names of variants and whose values are the 201 | percentage of requests that should see each variant. 202 | 203 | As a shorthand to support the common case of a feature with only one 204 | variant, `'enabled'` can also be specified as a percentage from 0 to 205 | 100 which is equivalent to specifying an array with the variant name 206 | `'on'` and the given percentage. 207 | 208 | The next four most important properties of a feature config stanza 209 | specify a particular variant that special classes of users should see: 210 | `'admin'`, `'internal'`, `'users'`, and `'groups'`. 211 | 212 | The `'admin'` and `'internal'` properties, if present, should name a 213 | variant that should be shown for all admin users or all internal 214 | requests. For single-variant features this name will almost always be 215 | `'on'`. (Technically you could also specify `'off'` to turn off a 216 | feature for admin users or internal requests that would be otherwise 217 | enabled. But that would be weird.) For multi-variant features it can 218 | be any of the variants mentioned in the `'enabled'` array. 219 | 220 | The `'users'` and `'groups'` variants provide a mapping from variant 221 | names to lists of users or numeric group ids. In the fully specified 222 | case, the value will be an array whose keys are the names of variants 223 | and whose values are lists of user names or group ids, as appropriate. 224 | As a shorthand, if the list of user names or group ids is a single 225 | element it can be specified with just the name or id. And as a further 226 | shorthand, in the configuration of a single-variant feature, the value 227 | of the `'users'` or `'groups'` property can simply be the value that 228 | should be assigned to the `'on'` variant. So using both shorthands, 229 | these are equivalent: 230 | 231 | $server_config['foo'] => array('users' => array('on' => array('fred'))); 232 | 233 | and: 234 | 235 | $server_config['foo'] => array('users' => 'fred'); 236 | 237 | None of these four properties have any effect if `'enabled'` is a 238 | string since in those cases the feature is considered either entirely 239 | enabled or disabled. They can, however, enable a variant of a feature 240 | if no `'enabled'` value is provided or if the variant’s percentage is 241 | 0. 242 | 243 | On the other hand, when an array `'enabled'` value is specified, as an 244 | aid to detecting typos, the variant names used in the `'admin'`, 245 | `'internal'`, `'users'`, and `'groups'` properties must also be keys 246 | in the `'enabled'` array. So if any variants are specified via 247 | `'enabled'`, they should all be, even if their percentage is set to 0. 248 | 249 | The two remaining feature config properties are `'bucketing'` and 250 | `'public_url_override'`. Bucketing specifies how users are bucketed 251 | when a feature is enabled for only a percentage of users. The default 252 | value, `'uaid'`, causes bucketing via the UAID cookie which means a 253 | user will be in the same bucket regardless of whether they are signed 254 | in or not. 255 | 256 | The bucketing value `'user'`, causes bucketing to be based on the 257 | signed-in user id. Currently we fall back to bucketing by UAID if the 258 | user is not signed in but this is problematic since it means that a 259 | user can switch buckets if they sign in or out. (We may change the 260 | behavior of this bucketing scheme to simply disable the feature for 261 | users who are not signed in.) 262 | 263 | Finally the bucketing value `'random'`, causes each request to be 264 | bucketed independently meaning that the same user will be in different 265 | buckets on different requests. This is typically used for features 266 | that should have no user-visible effects but where we want to ramp up 267 | something like the switch from master to shards or a new version of 268 | jquery. 269 | 270 | The `'public_url_override'` property allows all requests, not just 271 | admin and internal requests, to turn on a feature and choose a variant 272 | via the `features` query param. Its value will almost always be true 273 | if it is present since it defaults to false if omitted. 274 | 275 | Finally, two last shorthands: 276 | 277 | First, a config stanza with only the key `'enabled'` and a string 278 | value can be replaced with just the string. So: 279 | 280 | $server_config['foo'] = array('enabled' => 'on'); 281 | $server_config['bar'] = array('enabled' => 'off'); 282 | $server_config['baz'] = array('enabled' => 'some_variant'); 283 | 284 | Can be written simply: 285 | 286 | $server_config['foo'] = 'on'; 287 | $server_config['bar'] = 'off'; 288 | $server_config['baz'] = 'some_variant'; 289 | 290 | And second, if a feature config is missing entirely, it’s equivalent 291 | to specifying it as `'off'`. This allows dark changes to include code 292 | that checks for a feature before it has been added to production.php. 293 | 294 | **Note for ops**: removing a feature config altogether, setting it to 295 | the string `'off'`, or setting `'enabled'` to `'off'` all completely 296 | disable the feature, ensuring that code guarded by 297 | `Feature::isEnabled` for that feature will never run. The best way to 298 | turn off an existing feature in an emergency would be to set 299 | `'enabled'` to `'off'`. To facilitate that, we should try to keep the 300 | `'enabled'` value on one line, whenever possible. Thus: 301 | 302 | $server_config['foo'] = array( 303 | 'enabled' => array('foo' => 10, 'bar' => 10), 304 | ); 305 | 306 | rather than 307 | 308 | $server_config['foo'] = array( 309 | 'enabled' => array( 310 | 'foo' => 10, 311 | 'bar' => 10 312 | ), 313 | ); 314 | 315 | so that the bleary-eyed, junior ops person at 3am can do this: 316 | 317 | $server_config['foo'] = array( 318 | 'enabled' => 'off', // array('foo' => 10, 'bar' => 10), 319 | ); 320 | 321 | rather than this, which breaks the config file: 322 | 323 | $server_config['foo'] = array( 324 | 'enabled' => 'off', // array( 325 | 'foo' => 10, 326 | 'bar' => 10 327 | ), 328 | ); 329 | 330 | Note, however, that removing the `'enabled'` property does mostly turn 331 | off the feature it doesn’t completely disable it as it could still be 332 | enabled via an `'admin'` property, etc. 333 | 334 | ## Precedence: 335 | 336 | The precedence of the various mechanisms for enabling a feature are as 337 | follows. 338 | 339 | - If `'enabled'` is a string (variant name or `'off'`) the feature 340 | is entirely on or off for all requests. 341 | 342 | - Otherwise, if the request is from an admin user or is an internal 343 | request, or if `'public_url_override'` is true and the request 344 | contains a `features` query param that specifies a variant for the 345 | feature in question, that variant is used. The value of the 346 | `features` param is a comma-delimited list of features where each 347 | feature is either simply the name of the feature, indicating the 348 | feature should be enabled with variant `'on'` or the name of a 349 | feature, a colon, and the variant name. E.g. a request with 350 | `features=foo,bar:x,baz:off` would turn on feature `foo`, turn on 351 | feature `bar` with variant `x`, and turn off feature `baz`. 352 | 353 | - Otherwise, if the request is from a user specified in the 354 | `'users'` property, the specified variant is enabled. 355 | 356 | - Otherwise, if the request is from a member of a group specified in 357 | the `'groups'` property the specified variant is enabled. (The 358 | behavior when the user is a member of multiple groups that have 359 | been assigned different variants is undefined. Beware nasal 360 | demons.) 361 | 362 | - Otherwise, if the request is from an admin, the `'admin'` variant 363 | is enabled. 364 | 365 | - Otherwise, if the request is an internal request, the `'internal'` 366 | variant is enabled. 367 | 368 | - Otherwise, the request is bucketed and a variant is chosen so that 369 | the correct percentage of bucketed requests will see each variant. 370 | 371 | ## Errors 372 | 373 | There are a few ways to misuse the Feature API or misconfigure a 374 | feature that may be detected and logged. (Some of these are not 375 | currently detected but may be in the future.) 376 | 377 | 1. Calling `Feature::variant` for a single-variant feature. 378 | 379 | 1. Calling `Feature::variant` in code not guarded by an 380 | `Feature::isEnabled` check. 381 | 382 | 1. Including `'on'` as a variant name in a multi-variant feature. 383 | 384 | 1. Setting `'enabled'` to numeric value less than 0 or greater than 385 | 100. 386 | 387 | 1. Setting the percentage value of a variant in `'enabled'` to a 388 | value less than 0 or greater than 100. 389 | 390 | 1. Setting `'enabled'` such that the sum of the variant percentages 391 | is greater than 100. 392 | 393 | 1. Setting `'enabled'` to a non-numeric, non-string, non-array 394 | value. 395 | 396 | 1. When `'enabled'` is an array, setting the `'users'` or `'groups'` 397 | property to an array that includes a key that is not a key in 398 | `'enabled'`. 399 | 400 | 1. When `'enabled'` is an array, setting the `'admin'` or 401 | `'internal'` property to a value that is not a key in `'enabled'`. 402 | 403 | ## The life cycle of a feature 404 | 405 | The Feature API was designed with a eye toward making it a bit easier 406 | for us to push features through a predictable life cycle wherein a 407 | feature can be created easily, ramped up, A/B tested, and then cleaned 408 | up, either by being promoted to a full-fledged feature flag, by 409 | removing the configuration and associated feature checks but keeping 410 | the code, or deleting the code altogether. 411 | 412 | The basic life cycle of a feature might look like this: 413 | 414 | 1. Developer writes some code guarded by `Feature::isEnabled` 415 | checks. In order to test the feature in development they will add 416 | configuration for the feature to `development.php` that turns it 417 | on for specific users or admin or sets `'enabled'` to 0 so they 418 | can test it with a URL query param. 419 | 420 | 1. At some point the developer will add a config stanza to 421 | `production.php`. Initially this may just be a place holder that 422 | leaves the feature entirely disabled or it may turn it on for 423 | admin, etc. 424 | 425 | 1. Once the feature is done, the `production.php` config will be 426 | changed to enable the feature for a small percentage of users for 427 | an operational smoke test. For a single-variant feature this means 428 | setting `'enabled'` to a small numeric value; for a multi-variant 429 | feature it means setting `'enabled'` to an array that specifies a 430 | small percentage for each variant. 431 | 432 | 1. During the rampup period the percentage of users exposed to the 433 | feature may be moved up and down until the developers and ops 434 | folks are convinced the code is fully baked. If serious problems 435 | arise at any point, the new code can be completely disabled by 436 | setting enabled to `'off'`. 437 | 438 | 1. If the feature is going to be part of an A/B experiment, then the 439 | developers will (working with the data team) figure out the best 440 | percentage of users to expose the feature to and how long the 441 | experiment will have to run in order to gather good experimental 442 | data. To launch the experiment the production config will be 443 | changed to enable the feature or its variants for the appropriate 444 | percentage of users. After this point the percentages should be 445 | left alone until the experiment is complete. 446 | 447 | At this point there are a number of things that can happen: if the 448 | experiment revealed a clear winner we may simply want to keep the 449 | code, possibly putting it under control of a top-level feature flag 450 | that ops can use to disable the feature for operational reasons. Or we 451 | may want to discard all the code related to the feature. Or we may 452 | want to run another experiment based on what we learned from this one. 453 | Here’s what will happen in those cases: 454 | 455 | ### To keep the feature as a permanent part of the web site without creating a top-level feature flag 456 | 457 | 1. Change the value of the feature config to the name of the winning 458 | variant (`'on'` for a single-variant feature). 459 | 460 | 1. Delete any code that implements other variants and remove the 461 | calls to `Feature::variant` and any related conditional logic 462 | (e.g. switches on the variant name). 463 | 464 | 1. Remove the `Feature::isEnabled` checks but keep the code they 465 | guarded. 466 | 467 | 1. Remove the feature config. 468 | 469 | ### To keep a feature under the control of a full-fledged feature flag. (I.e. for things that will typically be enabled but which we want to preserve the ability to turn off with a simple config change.) 470 | 471 | 1. Change the value of the feature config to the name of the winning 472 | variant (`'on'` for a single-variant feature). 473 | 474 | 1. Delete any code that implements other variants and remove the 475 | calls to `Feature::variant` and any related conditional logic 476 | (e.g. switches on the variant name). 477 | 478 | 1. Add a new config named with a `feature_` prefix and set its value 479 | to `'on'`. 480 | 481 | 1. Change all the `Feature::isEnabled` checks for the old flag name 482 | to the new feature flag. 483 | 484 | 1. Remove the old config. 485 | 486 | ### To remove a feature all together 487 | 488 | 1. Change the value of the feature config to `'off'`. 489 | 490 | 1. Delete all code guarded by `Feature::isEnabled` checks and then 491 | remove the checks. 492 | 493 | 1. Remove the feature config. 494 | 495 | ### To run a new experiment based on the same code 496 | 497 | 1. Set the enabled value of the feature config to `'off'`. 498 | 499 | 1. Create a new feature config with a similar name but suffixed with 500 | _vN where N is 2 if this is the second experiment, 3 if is the 501 | third. Set it to `'off'`. 502 | 503 | 1. Change all the `Feature::isEnabled` checks for the old feature to 504 | the new feature. 505 | 506 | 1. Delete the old config. 507 | 508 | 1. Implement the changes required for the new experiment, deleting 509 | old variants and adding new ones as needed. 510 | 511 | 1. Rampup and then A/B test the new feature as normal. 512 | 513 | 1. Promote, cleanup, or re-experiment as appropriate. 514 | 515 | ## A few style guidelines 516 | 517 | To make it easier to push features through this life cycle there are a 518 | few coding guidelines to observe. 519 | 520 | First, the feature name argument to the Feature methods (`isEnabled`, 521 | `variant`, `isEnabledFor`, and `variantFor`) should always be a string 522 | literal. This will make it easier to find all the places that a 523 | particular feature is checked. If you find yourself creating feature 524 | names at run time and then checking them, you’re probably abusing the 525 | Feature system. Chances are in such a case you don’t really want to be 526 | using the Feature API but rather simply driving your code with some 527 | plain old config data. 528 | 529 | Second, the results of the Feature methods should not be cached, such 530 | as by calling `Feature::isEnabled` once and storing the result in an 531 | instance variable of some controller. The Feature machinery already 532 | caches the results of the computation it does so it should already be 533 | plenty fast to simply call `Feature::isEnabled` or `Feature::variant` 534 | whenever needed. This will again aid in finding the places that depend 535 | on a particular feature. 536 | 537 | Third, as a check that you’re using the Feature API properly, whenever 538 | you have an if block whose test is a call to `Feature::isEnabled`, 539 | make sure that it would make sense to either remove the check and keep 540 | the code or to delete the check and the code together. There shouldn’t 541 | be bits of code within a block guarded by an isEnabled check that 542 | needs to be salvaged if the feature is removed. 543 | --------------------------------------------------------------------------------