Skip to content
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions resources/views/module_update.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

?>
Expand Down Expand Up @@ -70,18 +72,19 @@ 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,
null,
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 class="visually-hidden">
<?= $caption ?? I18N::translate('Module Updates') ?>
Expand All @@ -96,6 +99,9 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes
<th><?= I18N::translate('Current Version') ?></th>
<th><?= I18N::translate('Latest Version') ?></th>
<th><?= I18N::translate('Update Service') ?></th>
<?php if ($telemetry_opt_in) : ?>
<th><?= I18N::translate('Installs') ?></th>
<?php endif ?>
<th><?= MoreI18N::xlate('Enabled') ?></th>
<th><?= I18N::translate('Install') ?></th>
<th><?= MoreI18N::xlate('Delete') ?></th>
Expand Down Expand Up @@ -240,6 +246,18 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\ReleaseNotes
<?= $module_update_service_name ?>
</td>

<!-- Installs -->
<?php if ($telemetry_opt_in) : ?>
<?php
$standard_module_name = ModuleUpdateServiceConfiguration::getStandardModuleName($module_name);
$telemetry_id = $standard_module_name !== '' ? $standard_module_name : $module_name;
$install_count = $telemetry_stats[$telemetry_id] ?? 0;
?>
<td data-sort="<?= $install_count ?>">
<?= $install_count > 0 ? $install_count : '-' ?>
</td>
<?php endif ?>

<!-- Enabled -->
<td data-sort="<?= $module_status ?>">
<?php if ($module !== null) : ?>
Expand Down
43 changes: 43 additions & 0 deletions resources/views/settings.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

?>
Expand Down Expand Up @@ -113,6 +116,46 @@ use Jefferson49\Webtrees\Module\CustomModuleManager\RequestHandlers\CustomModule
</div>

</div>

<div class="row mb-3"><?= view('icons/spacer') ?></div>

<div class="h4">
<?= I18N::translate('Community Telemetry') ?>
</div>
<p><?= I18N::translate('Anonymously share the list of installed custom modules to help the community understand module popularity.') ?></p>

<fieldset class="mb-3">
<div class="row">
<legend class="col-form-label col-sm-3">
<?= I18N::translate('Opt-in to anonymous community telemetry') ?>
</legend>
<div class="col-sm-9">
<?= view('components/checkbox', ['label' => I18N::translate('Opt-in to anonymous community telemetry'), 'name' => CustomModuleManager::PREF_TELEMETRY_OPT_IN, 'checked' => $telemetry_opt_in]) ?>
<div class="form-text">
<?= 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.') ?>
</div>
</div>
</div>
</fieldset>

<div class="row mb-3">
<label class="col-sm-3 col-form-label wt-page-options-label" for="telemetry_url">
<?= I18N::translate('Telemetry URL') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input class="form-control" id="telemetry_url" name="<?= CustomModuleManager::PREF_TELEMETRY_URL ?>" type="text" value="<?= e($telemetry_url) ?>">
</div>
</div>

<div class="row mb-3">
<label class="col-sm-3 col-form-label wt-page-options-label" for="telemetry_key">
<?= I18N::translate('Telemetry API key') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input class="form-control" id="telemetry_key" name="<?= CustomModuleManager::PREF_TELEMETRY_KEY ?>" type="text" value="<?= e($telemetry_key) ?>">
</div>
</div>

<div class="row">
<div class="col">
<p></p>
Expand Down
167 changes: 167 additions & 0 deletions src/CustomModuleManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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),
]
);
}
Expand All @@ -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
Expand Down Expand Up @@ -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<string> $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() .
'<br><strong>Request body:</strong> <code>' . e($request_body) . '</code>' .
'<br><strong>Response body:</strong> <code>' . e($response_body) . '</code>',
'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:' .
'<br><strong>Request body:</strong> <code>' . e($request_body) . '</code>' .
'<br><strong>Error:</strong> <code>' . e($error_detail) . '</code>',
'danger'
);
}
}

/**
* Fetch module installation statistics from the community telemetry service.
*
* @return array<string,int> 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() .
'<br><strong>Request body:</strong> <code>' . e($request_body) . '</code>' .
'<br><strong>Response body:</strong> <code>' . e($body) . '</code>',
'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:' .
'<br><strong>Request body:</strong> <code>' . e($request_body) . '</code>' .
'<br><strong>Error:</strong> <code>' . e($error_detail) . '</code>',
'danger'
);
$cached_stats = [];
}

return $cached_stats;
}

/**
* Whether the current version is the latest version of the module
*
Expand Down
18 changes: 17 additions & 1 deletion src/RequestHandlers/CustomModuleUpdatePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}