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="= e(json_encode([
+ data-columns="= e(json_encode(array_merge([
['type' => '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)) ?>"
>
= $caption ?? I18N::translate('Module Updates') ?>
@@ -96,6 +99,9 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes
= I18N::translate('Current Version') ?>
= I18N::translate('Latest Version') ?>
= I18N::translate('Update Service') ?>
+
+ = I18N::translate('Installs') ?>
+
= MoreI18N::xlate('Enabled') ?>
= I18N::translate('Install') ?>
= MoreI18N::xlate('Delete') ?>
@@ -240,6 +246,18 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes
= $module_update_service_name ?>
+
+
+
+
+ = $install_count > 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
+
+ = view('icons/spacer') ?>
+
+
+ = I18N::translate('Community Telemetry') ?>
+
+ = I18N::translate('Anonymously share the list of installed custom modules to help the community understand module popularity.') ?>
+
+
+
+
+ = I18N::translate('Opt-in to anonymous community telemetry') ?>
+
+
+ = view('components/checkbox', ['label' => I18N::translate('Opt-in to anonymous community telemetry'), 'name' => CustomModuleManager::PREF_TELEMETRY_OPT_IN, 'checked' => $telemetry_opt_in]) ?>
+
+ = I18N::translate('Help improve the webtrees ecosystem! If checked, your server will anonymously share which custom modules you have installed. No personal data, IP addresses, or family tree information is ever collected. You can disable this at any time.') ?>
+
+
+
+
+
+
+
+ = I18N::translate('Telemetry URL') ?>
+
+
+
+
+
+
+
+
+ = I18N::translate('Telemetry API key') ?>
+
+
+
+
+
+
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,
]);
}
}