diff --git a/README.md b/README.md index ca8b9f4..9ffe6a9 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ The module allows to provide a GitHub API token in the module settings. In order + Do NOT select any options + Press "Generate token" button at the bottom of the page +## Community Telemetry + +To help the community understand which custom modules are most actively used, this module includes a telemetry feature to collect aggregate data on module installations. + +This feature is enabled by default to help build accurate statistics, but can be easily opted out of by the site administrator in the module configuration. + +While enabled, the module will securely send an anonymous list of installed module names to a centralized database during the normal version-check routine. This data powers a feature in the Custom Module Manager, allowing you to see the global number of active installations for each module directly in the directory. + +Data Privacy Guarantees: + ++ Easy Opt-Out: Telemetry can be disabled at any time in the module settings. Once opted out, no data is sent. ++ Fully Anonymous: The system does not collect URLs, IP addresses, admin emails, or core database contents. Sites are identified only by a random site ID (the exact same ID used by the core webtrees installation to anonymously report PHP and database versions). ++ Ecosystem Only: The only data transmitted is an array of strings representing the internal names of installed custom modules. Not even the local folder names are sent. + ## How to use the module? + Go to "Control Panel/All Modules" and find the "Custom Module Manager" module diff --git a/resources/views/module_update.phtml b/resources/views/module_update.phtml index 800446e..45072ad 100644 --- a/resources/views/module_update.phtml +++ b/resources/views/module_update.phtml @@ -33,6 +33,8 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes * @var array $module_names * @var bool $fetch_latest * @var string $modules_to_show + * @var bool $telemetry_opt_in + * @var array $telemetry_stats */ ?> @@ -70,7 +72,7 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes data-info="false" data-paging="false" data-filter="false" - data-columns=" 'html'], ['type' => 'html'], null, @@ -78,10 +80,11 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes null, null, null, + ], $telemetry_opt_in ? [['type' => 'num']] : [], [ ['type' => 'html', 'searchable' => false], ['type' => 'html', 'searchable' => false], ['type' => 'html', 'searchable' => false], - ], JSON_THROW_ON_ERROR)) ?>" + ]), JSON_THROW_ON_ERROR)) ?>" > @@ -96,6 +99,9 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes + + + @@ -240,6 +246,18 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes + + + + + 0 ? $install_count : '-' ?> + + + diff --git a/resources/views/settings.phtml b/resources/views/settings.phtml index b26494d..0d1b041 100644 --- a/resources/views/settings.phtml +++ b/resources/views/settings.phtml @@ -17,6 +17,9 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\CustomModule * @var bool $php_extension_zip_missing * @var string $github_api_token * @var string $modules_to_show + * @var bool $telemetry_opt_in + * @var string $telemetry_url + * @var string $telemetry_key */ ?> @@ -113,6 +116,46 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\CustomModule + +
+ +
+ +
+

+ +
+
+ + + +
+ I18N::translate('Opt-in to anonymous community telemetry'), 'name' => CustomModuleManager::PREF_TELEMETRY_OPT_IN, 'checked' => $telemetry_opt_in]) ?> +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+

diff --git a/src/CustomModuleManager.php b/src/CustomModuleManager.php index 100a71b..fc2bf86 100644 --- a/src/CustomModuleManager.php +++ b/src/CustomModuleManager.php @@ -52,11 +52,14 @@ use Fisharebest\Webtrees\Module\ModuleListTrait; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\ModuleService; +use Fisharebest\Webtrees\Site; use Fisharebest\Webtrees\Validator; use Fisharebest\Webtrees\Session; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\View; use Fisharebest\Webtrees\Webtrees; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use Jefferson49\Webtrees\Exceptions\GithubCommunicationError; use Jefferson49\Webtrees\Helpers\GithubService; use Jefferson49\Webtrees\Log\CustomModuleLogInterface; @@ -118,6 +121,13 @@ class CustomModuleManager extends AbstractModule implements public const PREF_SHOW_ALL = 'show_all_modules'; public const PREF_SHOW_INSTALLED = 'show_installed_modules'; public const PREF_SHOW_NOT_INSTALLED = 'show_not_installed_modules'; + public const PREF_TELEMETRY_OPT_IN = 'telemetry_opt_in'; + public const PREF_TELEMETRY_URL = 'telemetry_url'; + public const PREF_TELEMETRY_KEY = 'telemetry_key'; + + //Telemetry defaults + public const DEFAULT_TELEMETRY_URL = 'https://fzzgvxqcqngxbhohnxdv.supabase.co/rest/v1/rpc'; + public const DEFAULT_TELEMETRY_KEY = 'sb_publishable_LYDPMFM9k8j1S8c9Ba1MGw_2MGiQxzP'; public const PREF_SHOW_MENU_LIST_ITEM = 'show_menu_list_item'; public const PREF_LATEST_VERSION = 'latest'; public const PREF_IGNORE_VERSION = 'ignore'; @@ -481,6 +491,9 @@ public function getAdminAction(ServerRequestInterface $request): ResponseInterfa self::PREF_GITHUB_API_TOKEN => $this->getPreference(self::PREF_GITHUB_API_TOKEN, ''), self::PREF_MODULES_TO_SHOW => $this->getPreference(self::PREF_MODULES_TO_SHOW, self::PREF_SHOW_ALL), self::PREF_SHOW_MENU_LIST_ITEM => boolval($this->getPreference(self::PREF_SHOW_MENU_LIST_ITEM, '1')), + self::PREF_TELEMETRY_OPT_IN => boolval($this->getPreference(self::PREF_TELEMETRY_OPT_IN, '1')), + self::PREF_TELEMETRY_URL => $this->getPreference(self::PREF_TELEMETRY_URL, self::DEFAULT_TELEMETRY_URL), + self::PREF_TELEMETRY_KEY => $this->getPreference(self::PREF_TELEMETRY_KEY, self::DEFAULT_TELEMETRY_KEY), ] ); } @@ -498,12 +511,18 @@ public function postAdminAction(ServerRequestInterface $request): ResponseInterf $github_api_token = Validator::parsedBody($request)->string(self::PREF_GITHUB_API_TOKEN, ''); $modules_to_show = Validator::parsedBody($request)->string(self::PREF_MODULES_TO_SHOW, self::PREF_SHOW_ALL); $show_menu_list_item = Validator::parsedBody($request)->boolean(self::PREF_SHOW_MENU_LIST_ITEM, false); + $telemetry_opt_in = Validator::parsedBody($request)->boolean(self::PREF_TELEMETRY_OPT_IN, false); + $telemetry_url = Validator::parsedBody($request)->string(self::PREF_TELEMETRY_URL, self::DEFAULT_TELEMETRY_URL); + $telemetry_key = Validator::parsedBody($request)->string(self::PREF_TELEMETRY_KEY, self::DEFAULT_TELEMETRY_KEY); //Save the received settings to the user preferences if ($save === '1') { $this->setPreference(self::PREF_GITHUB_API_TOKEN, $github_api_token); $this->setPreference(self::PREF_MODULES_TO_SHOW, $modules_to_show); $this->setPreference(self::PREF_SHOW_MENU_LIST_ITEM, $show_menu_list_item ? '1' : '0'); + $this->setPreference(self::PREF_TELEMETRY_OPT_IN, $telemetry_opt_in ? '1' : '0'); + $this->setPreference(self::PREF_TELEMETRY_URL, $telemetry_url); + $this->setPreference(self::PREF_TELEMETRY_KEY, $telemetry_key); } //Finally, show a success message @@ -838,6 +857,154 @@ public static function rememberGithubCommunciationError(): bool { return false; } + /** + * Submit telemetry data to the community telemetry service. + * Sends the list of installed custom module names along with a site UUID. + * + * @param array $module_names Array of installed custom module names + * + * @return void + */ + public function submitTelemetry(array $module_names): void + { + $opt_in = boolval($this->getPreference(self::PREF_TELEMETRY_OPT_IN, '1')); + + if (!$opt_in) { + return; + } + + $telemetry_url = $this->getPreference(self::PREF_TELEMETRY_URL, self::DEFAULT_TELEMETRY_URL); + $telemetry_key = $this->getPreference(self::PREF_TELEMETRY_KEY, self::DEFAULT_TELEMETRY_KEY); + + $site_uuid = Site::getPreference('SITE_UUID'); + + if ($site_uuid === '') { + $site_uuid = Registry::idFactory()->uuid(); + Site::setPreference('SITE_UUID', $site_uuid); + } + + // Resolve folder-based module names to standard config key names (globally unique) + $module_identifiers = []; + foreach ($module_names as $module_name) { + $standard_name = ModuleUpdateServiceConfiguration::getStandardModuleName($module_name); + $module_identifiers[] = $standard_name !== '' ? $standard_name : $module_name; + } + + $request_body = json_encode([ + 'p_site_uuid' => $site_uuid, + 'p_modules_list' => $module_identifiers, + ]); + + try { + $client = new Client(); + $response = $client->post($telemetry_url . '/submit_telemetry', [ + 'timeout' => 3.0, + 'headers' => [ + 'Content-Type' => 'application/json', + 'apikey' => $telemetry_key, + 'Authorization' => 'Bearer ' . $telemetry_key, + ], + 'body' => $request_body, + ]); + + $response_body = $response->getBody()->getContents(); + FlashMessages::addMessage( + 'Telemetry submit OK: HTTP ' . $response->getStatusCode() . + '
Request body: ' . e($request_body) . '' . + '
Response body: ' . e($response_body) . '', + 'info' + ); + } catch (Throwable $ex) { + $error_detail = $ex->getMessage(); + if ($ex instanceof RequestException && $ex->hasResponse()) { + $error_detail = $ex->getResponse()->getBody()->getContents(); + } + error_log('CustomModuleManager telemetry submit error: ' . $error_detail); + FlashMessages::addMessage( + 'Telemetry submit error:' . + '
Request body: ' . e($request_body) . '' . + '
Error: ' . e($error_detail) . '', + 'danger' + ); + } + } + + /** + * Fetch module installation statistics from the community telemetry service. + * + * @return array Mapping of module_name => active_installs count + */ + public function fetchTelemetryStatistics(): array + { + static $cached_stats = null; + + if ($cached_stats !== null) { + return $cached_stats; + } + + $opt_in = boolval($this->getPreference(self::PREF_TELEMETRY_OPT_IN, '1')); + + if (!$opt_in) { + $cached_stats = []; + return $cached_stats; + } + + $telemetry_url = $this->getPreference(self::PREF_TELEMETRY_URL, self::DEFAULT_TELEMETRY_URL); + $telemetry_key = $this->getPreference(self::PREF_TELEMETRY_KEY, self::DEFAULT_TELEMETRY_KEY); + + $request_body = json_encode((object) []); + + try { + $client = new Client(); + $response = $client->post($telemetry_url . '/get_module_statistics', [ + 'timeout' => 3.0, + 'headers' => [ + 'Content-Type' => 'application/json', + 'apikey' => $telemetry_key, + 'Authorization' => 'Bearer ' . $telemetry_key, + ], + 'body' => $request_body, + ]); + + $body = $response->getBody()->getContents(); + + FlashMessages::addMessage( + 'Telemetry stats OK: HTTP ' . $response->getStatusCode() . + '
Request body: ' . e($request_body) . '' . + '
Response body: ' . e($body) . '', + 'info' + ); + + $data = json_decode($body, true); + + $stats = []; + if (is_array($data)) { + foreach ($data as $entry) { + if (isset($entry['module_name'], $entry['active_installs'])) { + $stats[$entry['module_name']] = (int) $entry['active_installs']; + } + } + } + + $cached_stats = $stats; + } catch (Throwable $ex) { + $error_detail = $ex->getMessage(); + if ($ex instanceof RequestException && $ex->hasResponse()) { + $error_detail = $ex->getResponse()->getBody()->getContents(); + } + error_log('CustomModuleManager telemetry stats error: ' . $error_detail); + FlashMessages::addMessage( + 'Telemetry stats error:' . + '
Request body: ' . e($request_body) . '' . + '
Error: ' . e($error_detail) . '', + 'danger' + ); + $cached_stats = []; + } + + return $cached_stats; + } + /** * Whether the current version is the latest version of the module * diff --git a/src/RequestHandlers/CustomModuleUpdatePage.php b/src/RequestHandlers/CustomModuleUpdatePage.php index 293634b..9b55d7c 100644 --- a/src/RequestHandlers/CustomModuleUpdatePage.php +++ b/src/RequestHandlers/CustomModuleUpdatePage.php @@ -76,15 +76,31 @@ public function handle(ServerRequestInterface $request): ResponseInterface /** @var CustomModuleManager $custom_module_manager To avoid IDE warnings */ $custom_module_manager = $module_service->findByName(module_name: CustomModuleManager::activeModuleName()); + $module_names = ModuleUpdateServiceConfiguration::getModuleNames(); + + // Submit telemetry when checking for updates + if ($fetch_latest && $custom_module_manager !== null) { + $installed_modules = $module_service->findByInterface(ModuleCustomInterface::class, true) + ->map(static fn ($module) => $module->name()) + ->values() + ->all(); + $custom_module_manager->submitTelemetry($installed_modules); + } + + // Fetch telemetry statistics + $telemetry_stats = $custom_module_manager !== null ? $custom_module_manager->fetchTelemetryStatistics() : []; + return $this->viewResponse(CustomModuleManager::viewsNamespace() . '::module_update', [ 'title' => I18N::translate('Custom Module Updates'), 'custom_module_manager' => $custom_module_manager, 'module_service' => $module_service, - 'module_names' => ModuleUpdateServiceConfiguration::getModuleNames(), + 'module_names' => $module_names, 'custom_modules' => $module_service->findByInterface(ModuleCustomInterface::class, true), 'themes' => $module_service->findByInterface(ModuleThemeInterface::class, true), 'fetch_latest' => $fetch_latest, 'modules_to_show' => $custom_module_manager->getPreference(CustomModuleManager::PREF_MODULES_TO_SHOW, CustomModuleManager::PREF_SHOW_ALL), + 'telemetry_opt_in' => boolval($custom_module_manager->getPreference(CustomModuleManager::PREF_TELEMETRY_OPT_IN, '1')), + 'telemetry_stats' => $telemetry_stats, ]); } }