| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | <?php |
| 2 | + | |
| 2 | 3 | /** |
| 3 | 4 | * Insights setup |
| 4 | 5 | */ |
| 5 | 6 | |
| @@ -4,25 +5,41 @@ | ||
| 4 | 5 | */ |
| 5 | 6 | |
| 6 | 7 | namespace Extendify; |
| 7 | 8 | |
| 9 | +defined('ABSPATH') || die('No direct access.'); | |
| 10 | + | |
| 11 | +use Extendify\Shared\Services\Sanitizer; | |
| 12 | +use Extendify\PartnerData; | |
| 13 | + | |
| 8 | 14 | /** |
| 9 | 15 | * Controller for handling various Insights related things. |
| 16 | + * WP code reviewers: This is used in another plugin and not invoked here. | |
| 10 | 17 | */ |
| 18 | + | |
| 11 | 19 | class Insights |
| 12 | 20 | { |
| 21 | + /** | |
| 22 | + * Option name storing each site's A/B test assignments, keyed by the | |
| 23 | + * screen/feature under test (e.g. 'AutoLaunch.HideEnhanceAI'). | |
| 24 | + * | |
| 25 | + * @var string | |
| 26 | + */ | |
| 27 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound | |
| 28 | + const ACTIVE_TESTS_OPTION = 'extendify_active_tests'; | |
| 13 | 29 | |
| 14 | 30 | /** |
| 15 | - * An array of active tests. 'A' should be the control. | |
| 16 | - * For weighted tests, try ['A', 'A', 'A', 'A', 'B'] | |
| 31 | + * Tests the plugin knows how to run. Each is rolled independently against | |
| 32 | + * its own rollout percentage, which the partner config supplies. | |
| 17 | 33 | * |
| 18 | - * @var array | |
| 34 | + * @var string[] | |
| 19 | 35 | */ |
| 20 | - protected $activeTests = [ | |
| 21 | - 'remove-dont-see-inputs' => [ | |
| 22 | - 'A', | |
| 23 | - 'B', | |
| 24 | - ], | |
| 36 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound | |
| 37 | + const AVAILABLE_TESTS = [ | |
| 38 | + 'AutoLaunch.HideEnhanceAI', | |
| 39 | + 'AutoLaunch.SubmitCreateWebsite', | |
| 40 | + 'AutoLaunch.DescriptionPlaceholderLaw', | |
| 41 | + 'AutoLaunch.MigrateScreen', | |
| 25 | 42 | ]; |
| 26 | 43 | |
| 27 | 44 | /** |
| 28 | 45 | * Process the readme file to get version and name |
| @@ -30,26 +47,201 @@ | ||
| 30 | 47 | * @return void |
| 31 | 48 | */ |
| 32 | 49 | public function __construct() |
| 33 | 50 | { |
| 34 | - $this->setUpActiveTests(); | |
| 51 | + // If there isn't a siteId, then create one. | |
| 52 | + if (!\get_option('extendify_site_id', false)) { | |
| 53 | + \update_option('extendify_site_id', \wp_generate_uuid4()); | |
| 54 | + } | |
| 55 | + | |
| 56 | + if ( | |
| 57 | + defined('EXTENDIFY_INSIGHTS_URL') | |
| 58 | + && class_exists('ExtendifyInsights') | |
| 59 | + && !\get_option('extendify_insights_checkedin_once', 0) | |
| 60 | + ) { | |
| 61 | + \update_option('extendify_insights_checkedin_once', gmdate('Y-m-d H:i:s')); | |
| 62 | + // WP code reviewers: This job is defined in another plugin (i.e. it's opt-in). | |
| 63 | + \add_action('init', function () { | |
| 64 | + // Run this once but wait 10 minutes. | |
| 65 | + \wp_schedule_single_event((time() + 10 * MINUTE_IN_SECONDS), 'extendify_insights'); | |
| 66 | + \spawn_cron(); | |
| 67 | + }); | |
| 68 | + } | |
| 69 | + | |
| 70 | + $this->filterExternalInsights(); | |
| 71 | + $this->setupAdminLoginInsights(); | |
| 35 | 72 | } |
| 36 | 73 | |
| 37 | 74 | /** |
| 38 | - * Returns the active tests for the user, and sets up tests as needed. | |
| 75 | + * Assign A/B variants for the known tests based on the partner's active | |
| 76 | + * tests. Each active test is rolled once; | |
| 77 | + * inactive tests are dropped. | |
| 39 | 78 | * |
| 79 | + * @param string[] $activeTests Active tests in `Name:Percentage` form | |
| 80 | + * (e.g. 'AutoLaunch.HideEnhanceAI:20'); a bare | |
| 81 | + * name defaults to a 50% rollout. | |
| 40 | 82 | * @return void |
| 41 | 83 | */ |
| 42 | - public function setUpActiveTests() | |
| 84 | + public static function setup(array $activeTests = []) | |
| 43 | 85 | { |
| 44 | - // Make sure that the active tests are set. | |
| 45 | - $currentTests = \get_option('extendify_active_tests', []); | |
| 46 | - $newTests = array_map(function ($test) { | |
| 47 | - // Pick from value randomly. | |
| 48 | - return $test[array_rand($test)]; | |
| 49 | - }, array_diff_key($this->activeTests, $currentTests)); | |
| 50 | - $testsCombined = array_merge($currentTests, $newTests); | |
| 51 | - if ($newTests) { | |
| 52 | - \update_option('extendify_active_tests', $testsCombined); | |
| 86 | + $assignments = \get_option(self::ACTIVE_TESTS_OPTION, []); | |
| 87 | + | |
| 88 | + $percentages = []; | |
| 89 | + foreach ($activeTests as $entry) { | |
| 90 | + list($key, $percentage) = array_pad(explode(':', $entry, 2), 2, null); | |
| 91 | + $percentages[$key] = is_numeric($percentage) ? (float) $percentage : 50.0; | |
| 53 | 92 | } |
| 93 | + | |
| 94 | + foreach (self::AVAILABLE_TESTS as $key) { | |
| 95 | + if (!array_key_exists($key, $percentages)) { | |
| 96 | + unset($assignments[$key]); | |
| 97 | + continue; | |
| 98 | + } | |
| 99 | + | |
| 100 | + // Roll once so the site keeps the same variant. | |
| 101 | + if (!isset($assignments[$key])) { | |
| 102 | + $assignments[$key] = [ | |
| 103 | + // The percentage is variant B's rollout share: 50 -> 50% A / 50% B, | |
| 104 | + // 20 -> 80% A / 20% B. | |
| 105 | + 'variant' => random_int(1, 10000) <= $percentages[$key] * 100 ? 'B' : 'A', | |
| 106 | + 'percentage' => $percentages[$key], | |
| 107 | + // ISO 8601 (UTC) | |
| 108 | + 'assignedAt' => gmdate('c'), | |
| 109 | + ]; | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + \update_option(self::ACTIVE_TESTS_OPTION, Sanitizer::sanitizeArray($assignments)); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * Add additional data to the opt-in insights | |
| 118 | + * | |
| 119 | + * @return void | |
| 120 | + */ | |
| 121 | + public function filterExternalInsights() | |
| 122 | + { | |
| 123 | + add_filter('extendify_insights_data', function ($data) { | |
| 124 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents | |
| 125 | + $readme = file_get_contents(EXTENDIFY_PATH . 'readme.txt'); | |
| 126 | + preg_match('/Stable tag: ([0-9.:]+)/', $readme, $version); | |
| 127 | + | |
| 128 | + $insights = array_merge($data, [ | |
| 129 | + 'launch' => Config::$showLaunch, | |
| 130 | + 'launchRedirectedAt' => \get_option('extendify_attempted_redirect', null), | |
| 131 | + 'launchLoadedAt' => \get_option('extendify_launch_loaded', null), | |
| 132 | + 'partner' => defined('EXTENDIFY_PARTNER_ID') ? constant('EXTENDIFY_PARTNER_ID') : null, | |
| 133 | + 'siteCreatedAt' => SiteSettings::getSiteCreatedAt(), | |
| 134 | + 'assistRouterData' => \get_option('extendify_assist_router', null), | |
| 135 | + 'libraryData' => \get_option('extendify_library_site_data', null), | |
| 136 | + 'draftSettingsData' => \get_option('extendify_draft_settings', null), | |
| 137 | + 'activity' => \get_option('extendify_shared_activity', null), | |
| 138 | + 'domainsActivities' => \get_option('extendify_domains_recommendations_activities', null), | |
| 139 | + 'extendifyVersion' => ($version[1] ?? null), | |
| 140 | + 'siteProfile' => \get_option('extendify_site_profile', null), | |
| 141 | + 'pluginSearchTerms' => \get_option('extendify_plugin_search_terms', []), | |
| 142 | + 'blockSearchTerms' => \get_option('extendify_block_search_terms', []), | |
| 143 | + 'phpVersion' => PHP_VERSION, | |
| 144 | + 'themeSearchTerms' => \get_option('extendify_theme_search_terms', []), | |
| 145 | + 'license' => PartnerData::setting('license'), | |
| 146 | + 'pagesCount' => $this->getPostsCount('page'), | |
| 147 | + 'postsCount' => $this->getPostsCount('post'), | |
| 148 | + 'lastUpdatedPage' => $this->getLastUpdatedPost('page'), | |
| 149 | + 'lastUpdatedPost' => $this->getLastUpdatedPost('post'), | |
| 150 | + 'lastLoginAdmin' => $this->getLastAdminLogin(), | |
| 151 | + 'hasImprint' => $this->hasImprint(), | |
| 152 | + ]); | |
| 153 | + return $insights; | |
| 154 | + }); | |
| 155 | + } | |
| 156 | + | |
| 157 | + /** | |
| 158 | + * Get the number of posts/pages | |
| 159 | + * | |
| 160 | + * @param string $type The type of post/page to get the count for (post or page) | |
| 161 | + * @return int The number of posts/pages | |
| 162 | + */ | |
| 163 | + protected function getPostsCount($type = 'post') | |
| 164 | + { | |
| 165 | + $count = wp_count_posts($type); | |
| 166 | + return isset($count->publish) ? (int) $count->publish : 0; | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * Set up admin login insights to monitor when admin users log in | |
| 171 | + * | |
| 172 | + * @return void | |
| 173 | + */ | |
| 174 | + protected function setupAdminLoginInsights() | |
| 175 | + { | |
| 176 | + add_action('wp_login', function ($user_login, $user) { | |
| 177 | + // Only get insights for admin users | |
| 178 | + if (user_can($user, 'manage_options')) { | |
| 179 | + update_user_meta($user->ID, 'extendify_last_login', gmdate('Y-m-d H:i:s')); | |
| 180 | + } | |
| 181 | + }, 10, 2); | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * Get the last time a post/page was updated | |
| 186 | + * | |
| 187 | + * @return string|null The last updated post timestamp or null if no posts found | |
| 188 | + */ | |
| 189 | + protected function getLastUpdatedPost($type = 'post') | |
| 190 | + { | |
| 191 | + $posts = get_posts([ | |
| 192 | + 'post_type' => $type, | |
| 193 | + 'post_status' => 'publish', | |
| 194 | + 'orderby' => 'modified', | |
| 195 | + 'order' => 'DESC', | |
| 196 | + 'numberposts' => 1, | |
| 197 | + 'fields' => 'ids' | |
| 198 | + ]); | |
| 199 | + | |
| 200 | + if (!empty($posts)) { | |
| 201 | + $post = get_post($posts[0]); | |
| 202 | + return $post ? $post->post_modified : null; | |
| 203 | + } | |
| 204 | + return null; | |
| 205 | + } | |
| 206 | + | |
| 207 | + /** | |
| 208 | + * Get the last time an admin user logged in | |
| 209 | + * | |
| 210 | + * @return string|null The most recent admin login timestamp or null if no data found | |
| 211 | + */ | |
| 212 | + protected function getLastAdminLogin() | |
| 213 | + { | |
| 214 | + $admins = get_users([ | |
| 215 | + 'role' => 'administrator', | |
| 216 | + 'meta_key' => 'extendify_last_login', | |
| 217 | + 'orderby' => 'meta_value', | |
| 218 | + 'order' => 'DESC', | |
| 219 | + 'number' => 1 | |
| 220 | + ]); | |
| 221 | + | |
| 222 | + if (!empty($admins)) { | |
| 223 | + return get_user_meta($admins[0]->ID, 'extendify_last_login', true); | |
| 224 | + } | |
| 225 | + return null; | |
| 226 | + } | |
| 227 | + | |
| 228 | + /** | |
| 229 | + * Check if the site has an imprint based on the site profile and language settings | |
| 230 | + * | |
| 231 | + * @return bool True if the site has an imprint, false otherwise | |
| 232 | + */ | |
| 233 | + protected function hasImprint() | |
| 234 | + { | |
| 235 | + $siteProfile = \get_option('extendify_site_profile', []); | |
| 236 | + if (empty($siteProfile)) { | |
| 237 | + return false; | |
| 238 | + } | |
| 239 | + | |
| 240 | + $imprintLanguages = array_filter(PartnerData::setting('showImprint') ?? [], function ($value) { | |
| 241 | + return $value === get_locale(); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + return !empty($imprintLanguages) && (strtolower($siteProfile['aiSiteCategory']) === 'business' || | |
| 245 | + strtolower($siteProfile['category']) === 'business'); | |
| 54 | 246 | } |
| 55 | 247 | } |