diff --git a/.gitignore b/.gitignore index 283d5b7f0..b07b73d19 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ vendor/ data/database.sqlite data/shlink-tests.db data/GeoLite2-City.* +data/infra/matomo docs/swagger-ui* docs/mercure.html docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index e667fd197..353a4abfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. + * [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role. Keys with this role will always get `0` when fetching orphan visits. diff --git a/composer.json b/composer.json index 4f036a179..8ccb6e140 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "laminas/laminas-stdlib": "^3.17", "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", + "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.10", "mezzio/mezzio-problem-details": "^1.13", @@ -47,9 +48,9 @@ "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "dev-main#7d46772 as 5.7", "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", - "shlinkio/shlink-event-dispatcher": "dev-main#faf2582 as 3.1", + "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#c1ef08c as 8.6", + "shlinkio/shlink-installer": "dev-develop#c505a19 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 0aa849e05..e48b0ec73 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -66,6 +66,10 @@ Option\RabbitMq\RabbitMqUserConfigOption::class, Option\RabbitMq\RabbitMqPasswordConfigOption::class, Option\RabbitMq\RabbitMqVhostConfigOption::class, + Option\Matomo\MatomoEnabledConfigOption::class, + Option\Matomo\MatomoBaseUrlConfigOption::class, + Option\Matomo\MatomoSiteIdConfigOption::class, + Option\Matomo\MatomoApiTokenConfigOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php new file mode 100644 index 000000000..120ad2898 --- /dev/null +++ b/config/autoload/matomo.global.php @@ -0,0 +1,16 @@ + [ + 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), + 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), + 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), + 'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), + ], + +]; diff --git a/config/autoload/matomo.local.php.dist b/config/autoload/matomo.local.php.dist new file mode 100644 index 000000000..2a9404071 --- /dev/null +++ b/config/autoload/matomo.local.php.dist @@ -0,0 +1,26 @@ + [ +// 'enabled' => true, +// 'base_url' => 'http://shlink_matomo', +// 'site_id' => '...', +// 'api_token' => '...', + ], + +]; diff --git a/config/config.php b/config/config.php index 9df291383..a52ade5a8 100644 --- a/config/config.php +++ b/config/config.php @@ -22,33 +22,39 @@ $isTestEnv = env('APP_ENV') === 'test'; $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner(); -return (new ConfigAggregator\ConfigAggregator([ - ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) - : new ConfigAggregator\ArrayProvider([]), - Mezzio\ConfigProvider::class, - Mezzio\Router\ConfigProvider::class, - Mezzio\Router\FastRouteRouter\ConfigProvider::class, - $enableSwoole && class_exists(Swoole\ConfigProvider::class) - ? Swoole\ConfigProvider::class - : new ConfigAggregator\ArrayProvider([]), - ProblemDetails\ConfigProvider::class, - Diactoros\ConfigProvider::class, - Common\ConfigProvider::class, - Config\ConfigProvider::class, - Importer\ConfigProvider::class, - IpGeolocation\ConfigProvider::class, - EventDispatcher\ConfigProvider::class, - Core\ConfigProvider::class, - CLI\ConfigProvider::class, - Rest\ConfigProvider::class, - new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'), - // Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests - new ConfigAggregator\PhpFileProvider($isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php'), - // Routes have to be loaded last - new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), -], 'data/cache/app_config.php', [ - Core\Config\PostProcessor\BasePathPrefixer::class, - Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, - Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, -]))->getMergedConfig(); +return (new ConfigAggregator\ConfigAggregator( + providers: [ + ! $isTestEnv + ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) + : new ConfigAggregator\ArrayProvider([]), + Mezzio\ConfigProvider::class, + Mezzio\Router\ConfigProvider::class, + Mezzio\Router\FastRouteRouter\ConfigProvider::class, + $enableSwoole && class_exists(Swoole\ConfigProvider::class) + ? Swoole\ConfigProvider::class + : new ConfigAggregator\ArrayProvider([]), + ProblemDetails\ConfigProvider::class, + Diactoros\ConfigProvider::class, + Common\ConfigProvider::class, + Config\ConfigProvider::class, + Importer\ConfigProvider::class, + IpGeolocation\ConfigProvider::class, + EventDispatcher\ConfigProvider::class, + Core\ConfigProvider::class, + CLI\ConfigProvider::class, + Rest\ConfigProvider::class, + new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'), + // Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests + new ConfigAggregator\PhpFileProvider( + $isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php', + ), + // Routes have to be loaded last + new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), + ], + cachedConfigFile: 'data/cache/app_config.php', + postProcessors: [ + Core\Config\PostProcessor\BasePathPrefixer::class, + Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, + ], +))->getMergedConfig(); diff --git a/docker-compose.yml b/docker-compose.yml index a398d4bc5..e44ca82b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -70,6 +71,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -95,6 +97,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -201,3 +204,21 @@ services: - "8005:8080" volumes: - ./docs/swagger:/app + + shlink_matomo: + container_name: shlink_matomo + image: matomo:4.15-apache + ports: + - "8003:80" + volumes: + # Matomo does not persist port in trusted hosts. This volume is needed to edit config afterward + # https://github.com/matomo-org/matomo/issues/9549 + - ./data/infra/matomo:/var/www/html + links: + - shlink_db_mysql + environment: + MATOMO_DATABASE_HOST: "shlink_db_mysql" + MATOMO_DATABASE_ADAPTER: "mysql" + MATOMO_DATABASE_DBNAME: "matomo" + MATOMO_DATABASE_USERNAME: "root" + MATOMO_DATABASE_PASSWORD: "root" diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a245b10ed..591fcc796 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -92,6 +92,9 @@ Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, + + Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'], + Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -100,6 +103,8 @@ ], ConfigAbstractFactory::class => [ + Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], + ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index ac8626e81..1a81d8eda 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -18,152 +20,178 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -return [ +use function Shlinkio\Shlink\Config\runningInOpenswoole; +use function Shlinkio\Shlink\Config\runningInRoadRunner; - 'events' => [ - 'regular' => [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, - ], - EventDispatcher\Event\GeoLiteDbCreated::class => [ - EventDispatcher\LocateUnlocatedVisits::class, - ], +return (static function (): array { + $regularEvents = [ + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], - 'async' => [ - EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\Mercure\NotifyVisitToMercure::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, - EventDispatcher\NotifyVisitToWebHooks::class, - EventDispatcher\UpdateGeoLiteDb::class, - ], - EventDispatcher\Event\ShortUrlCreated::class => [ - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, - ], + EventDispatcher\Event\GeoLiteDbCreated::class => [ + EventDispatcher\LocateUnlocatedVisits::class, ], - ], - - 'dependencies' => [ - 'factories' => [ - EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, - EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, - - EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ]; + $asyncEvents = [ + EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, + EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\UpdateGeoLiteDb::class, + ], + EventDispatcher\Event\ShortUrlCreated::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, + ], + ]; + + // Send visits to matomo asynchronously if the runtime allows it + if (runningInRoadRunner() || runningInOpenswoole()) { + $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; + } else { + $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; + } + + return [ + + 'events' => [ + 'regular' => $regularEvents, + 'async' => $asyncEvents, ], - 'aliases' => [ - EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + 'dependencies' => [ + 'factories' => [ + EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, + EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, + EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, + + EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + ], + + 'delegators' => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\NotifyVisitToWebHooks::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + ], ], - 'delegators' => [ + ConfigAbstractFactory::class => [ + EventDispatcher\LocateVisit::class => [ + IpLocationResolverInterface::class, + 'em', + 'Logger_Shlink', + DbUpdater::class, + EventDispatcherInterface::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], + EventDispatcher\NotifyVisitToWebHooks::class => [ + 'httpClient', + 'em', + 'Logger_Shlink', + Options\WebhookOptions::class, + ShortUrl\Transformer\ShortUrlDataTransformer::class, + Options\AppOptions::class, + ], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Visit\Transformer\OrphanVisitDataTransformer::class, + Options\RabbitMqOptions::class, ], EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Options\RabbitMqOptions::class, ], EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], - EventDispatcher\LocateUnlocatedVisits::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\Matomo\SendVisitToMatomo::class => [ + 'em', + 'Logger_Shlink', + ShortUrlStringifier::class, + Matomo\MatomoOptions::class, + Matomo\MatomoTrackerBuilder::class, ], - EventDispatcher\NotifyVisitToWebHooks::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\UpdateGeoLiteDb::class => [ + GeolocationDbUpdater::class, + 'Logger_Shlink', + EventDispatcherInterface::class, ], - ], - ], - - ConfigAbstractFactory::class => [ - EventDispatcher\LocateVisit::class => [ - IpLocationResolverInterface::class, - 'em', - 'Logger_Shlink', - DbUpdater::class, - EventDispatcherInterface::class, - ], - EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], - EventDispatcher\NotifyVisitToWebHooks::class => [ - 'httpClient', - 'em', - 'Logger_Shlink', - Options\WebhookOptions::class, - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Options\AppOptions::class, - ], - EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Visit\Transformer\OrphanVisitDataTransformer::class, - Options\RabbitMqOptions::class, - ], - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Options\RabbitMqOptions::class, - ], - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\UpdateGeoLiteDb::class => [ - GeolocationDbUpdater::class, - 'Logger_Shlink', - EventDispatcherInterface::class, - ], - EventDispatcher\Helper\EnabledListenerChecker::class => [ - Options\RabbitMqOptions::class, - 'config.redis.pub_sub_enabled', - MercureOptions::class, - Options\WebhookOptions::class, - GeoLite2Options::class, + EventDispatcher\Helper\EnabledListenerChecker::class => [ + Options\RabbitMqOptions::class, + 'config.redis.pub_sub_enabled', + MercureOptions::class, + Options\WebhookOptions::class, + GeoLite2Options::class, + MatomoOptions::class, + ], ], - ], -]; + ]; +})(); diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 1a9179288..c966043fa 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -24,11 +24,6 @@ enum EnvVars: string case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET'; - case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; - case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; - case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; - case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; - case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED'; case RABBITMQ_HOST = 'RABBITMQ_HOST'; case RABBITMQ_PORT = 'RABBITMQ_PORT'; @@ -37,6 +32,15 @@ enum EnvVars: string case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; /** @deprecated */ case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; + case MATOMO_ENABLED = 'MATOMO_ENABLED'; + case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; + case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; + case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN'; + case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; + case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; + case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; + case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; + case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 907b3d9c2..87f7dba2a 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -9,17 +9,19 @@ abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable { - final public function __construct(public readonly string $visitId) - { + final public function __construct( + public readonly string $visitId, + public readonly ?string $originalIpAddress = null, + ) { } public function jsonSerialize(): array { - return ['visitId' => $this->visitId]; + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; } public static function fromPayload(array $payload): self { - return new static($payload['visitId'] ?? ''); + return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); } } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index c57d59d6a..d1158a4ed 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,18 +6,4 @@ final class UrlVisited extends AbstractVisitEvent { - private ?string $originalIpAddress = null; - - public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self - { - $instance = new self($visitId); - $instance->originalIpAddress = $originalIpAddress; - - return $instance; - } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } } diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php index 97c0ca5d2..269aed76f 100644 --- a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -6,6 +6,7 @@ use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -19,6 +20,7 @@ public function __construct( private readonly MercureOptions $mercureOptions, private readonly WebhookOptions $webhookOptions, private readonly GeoLite2Options $geoLiteOptions, + private readonly MatomoOptions $matomoOptions, ) { } @@ -35,6 +37,7 @@ public function shouldRegisterListener(string $event, string $listener, bool $is EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled, EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(), + EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled, EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), default => false, // Any unknown async listener should not be enabled by default diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index ba3ac3f0f..f139c0f53 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -41,8 +41,8 @@ public function __invoke(UrlVisited $shortUrlVisited): void return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); + $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); } private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php new file mode 100644 index 000000000..ad9660cba --- /dev/null +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -0,0 +1,89 @@ +matomoOptions->enabled) { + return; + } + + $visitId = $visitLocated->visitId; + + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to send visit with id "{visitId}" to matomo, but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + try { + $tracker = $this->trackerBuilder->buildMatomoTracker(); + + $tracker + ->setUrl($this->resolveUrlToTrack($visit)) + ->setCustomTrackingParameter('type', $visit->type()->value) + ->setUserAgent($visit->userAgent()) + ->setUrlReferrer($visit->referer()); + + $location = $visit->getVisitLocation(); + if ($location !== null) { + $tracker + ->setCity($location->getCityName()) + ->setCountry($location->getCountryName()) + ->setLatitude($location->getLatitude()) + ->setLongitude($location->getLongitude()); + } + + // Set not obfuscated IP if possible, as matomo handles obfuscation itself + $ip = $visitLocated->originalIpAddress ?? $visit->getRemoteAddr(); + if ($ip !== null) { + $tracker->setIp($ip); + } + + if ($visit->isOrphan()) { + $tracker->setCustomTrackingParameter('orphan', 'true'); + } + + // Send empty document title to avoid different actions to be created by matomo + $tracker->doTrackPageView(''); + } catch (Throwable $e) { + // Capture all exceptions to make sure this does not interfere with the regular execution + $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); + } + } + + public function resolveUrlToTrack(Visit $visit): string + { + $shortUrl = $visit->getShortUrl(); + if ($shortUrl === null) { + return $visit->visitedUrl() ?? ''; + } + + return $this->shortUrlStringifier->stringify($shortUrl); + } +} diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php new file mode 100644 index 000000000..235993211 --- /dev/null +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -0,0 +1,27 @@ +siteId === null) { + return null; + } + + // We enforce site ID to be hydrated as a numeric string or int, so it's safe to cast to int here + return (int) $this->siteId; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php new file mode 100644 index 000000000..4bad67990 --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -0,0 +1,48 @@ +options->siteId(); + if ($siteId === null || $this->options->baseUrl === null || $this->options->apiToken === null) { + throw new RuntimeException( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + } + + // Create a new MatomoTracker on every request, because it infers request info during construction + $tracker = new MatomoTracker($siteId, $this->options->baseUrl); + $tracker + // Token required to set the IP and location + ->setTokenAuth($this->options->apiToken) + // Ensure params are not sent in the URL, for security reasons + ->setRequestMethodNonBulk('POST') + // Set a reasonable timeout + ->setRequestTimeout(self::MATOMO_DEFAULT_TIMEOUT) + ->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT); + + // We don't want to bulk send, as every request to Shlink will create a new tracker + $tracker->disableBulkTracking(); + // Disable cookies, as they are ignored anyway + $tracker->disableCookieSupport(); + + return $tracker; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php new file mode 100644 index 000000000..7601f17a1 --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php @@ -0,0 +1,16 @@ +date; } + public function userAgent(): string + { + return $this->userAgent; + } + + public function referer(): string + { + return $this->referer; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index dd5fff917..9e4b88dfd 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -75,6 +75,6 @@ private function trackVisit(callable $createVisit, Visitor $visitor): void $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index de5017bdd..00f78fe46 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker; +use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; @@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; @@ -26,7 +28,7 @@ class EnabledListenerCheckerTest extends TestCase #[Test, DataProvider('provideListeners')] public function syncListenersAreRegisteredByDefault(string $listener): void { - self::assertTrue($this->checker()->shouldRegisterListener('', $listener, false)); + self::assertTrue($this->checker()->shouldRegisterListener(event: '', listener: $listener, isAsync: false)); } public static function provideListeners(): iterable @@ -38,6 +40,7 @@ public static function provideListeners(): iterable [NotifyNewShortUrlToRedis::class], [NotifyVisitToMercure::class], [NotifyNewShortUrlToMercure::class], + [SendVisitToMatomo::class], [NotifyVisitToWebHooks::class], [UpdateGeoLiteDb::class], ]; @@ -113,6 +116,18 @@ public static function provideConfiguredCheckers(): iterable UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; + yield 'Matomo' => [self::checker(matomoEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + SendVisitToMatomo::class => true, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; yield 'All disabled' => [self::checker(), [ NotifyVisitToRabbitMq::class => false, NotifyNewShortUrlToRabbitMq::class => false, @@ -130,6 +145,7 @@ public static function provideConfiguredCheckers(): iterable mercureEnabled: true, webhooksEnabled: true, geoLiteEnabled: true, + matomoEnabled: true, ), [ NotifyVisitToRabbitMq::class => true, NotifyNewShortUrlToRabbitMq::class => true, @@ -137,6 +153,7 @@ public static function provideConfiguredCheckers(): iterable NotifyNewShortUrlToRedis::class => true, NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, + SendVisitToMatomo::class => true, NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => true, 'unknown' => false, @@ -149,6 +166,7 @@ private static function checker( bool $mercureEnabled = false, bool $webhooksEnabled = false, bool $geoLiteEnabled = false, + bool $matomoEnabled = false, ): EnabledListenerChecker { return new EnabledListenerChecker( new RabbitMqOptions(enabled: $rabbitMqEnabled), @@ -156,6 +174,7 @@ private static function checker( new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), + new MatomoOptions(enabled: $matomoEnabled), ); } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index b6f214951..ddadde842 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -159,7 +159,7 @@ public function locatableVisitsResolveToLocation(Visit $visit, ?string $original { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); + $event = new UrlVisited('123', $originalIpAddress); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); $this->em->expects($this->once())->method('flush'); @@ -168,7 +168,9 @@ public function locatableVisitsResolveToLocation(Visit $visit, ?string $original $location, ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with( + new VisitLocated('123', $originalIpAddress), + ); $this->logger->expects($this->never())->method('warning'); ($this->locateVisit)($event); diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php new file mode 100644 index 000000000..94c666232 --- /dev/null +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -0,0 +1,188 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + } + + #[Test] + public function visitIsNotSentWhenMatomoIsDisabled(): void + { + $this->em->expects($this->never())->method('find'); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener(enabled: false))(new VisitLocated('123')); + } + + #[Test] + public function visitIsNotSentWhenItDoesNotExist(): void + { + $this->em->expects($this->once())->method('find')->willReturn(null); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', + ['visitId' => '123'], + ); + + ($this->listener())(new VisitLocated('123')); + } + + #[Test, DataProvider('provideTrackerMethods')] + public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView')->with(''); + + if ($visit->isOrphan()) { + $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ + ['type', $visit->type()->value, $tracker], + ['orphan', 'true', $tracker], + ]); + } else { + $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( + 'type', + $visit->type()->value, + )->willReturn($tracker); + } + + foreach ($invokedMethods as $invokedMethod) { + $tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker); + } + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); + } + + public static function provideTrackerMethods(): iterable + { + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'located regular visit' => [ + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + ->locate(VisitLocation::fromGeolocation(new Location( + countryCode: 'countryCode', + countryName: 'countryName', + regionName: 'regionName', + city: 'city', + latitude: 123, + longitude: 123, + timeZone: 'timeZone', + ))), + '1.2.3.4', + ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], + ]; + yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + } + + #[Test, DataProvider('provideUrlsToTrack')] + public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView'); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId)); + } + + public static function provideUrlsToTrack(): iterable + { + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit with visited URL' => [ + Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + 'https://s.test/foo', + ]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl(ShortUrl::create( + ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ShortUrlInputFilter::CUSTOM_SLUG => 'bar', + ]), + ), Visitor::emptyInstance()), + 'http://s2.test/bar', + ]; + } + + #[Test] + public function logsErrorWhenTrackingFails(): void + { + $visitId = '123'; + $e = new Exception('Error!'); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + $this->createMock(Visit::class), + ); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willThrowException($e); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->once())->method('error')->with( + 'An error occurred while trying to send visit to Matomo. {e}', + ['e' => $e], + ); + + ($this->listener())(new VisitLocated($visitId)); + } + + private function listener(bool $enabled = true): SendVisitToMatomo + { + return new SendVisitToMatomo( + $this->em, + $this->logger, + new ShortUrlStringifier(['hostname' => 's2.test']), + new MatomoOptions(enabled: $enabled), + $this->trackerBuilder, + ); + } +} diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php new file mode 100644 index 000000000..5a4e6ab0f --- /dev/null +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -0,0 +1,51 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + $this->builder($options)->buildMatomoTracker(); + } + + public static function provideInvalidOptions(): iterable + { + yield [new MatomoOptions()]; + yield [new MatomoOptions(baseUrl: 'base_url')]; + yield [new MatomoOptions(apiToken: 'api_token')]; + yield [new MatomoOptions(siteId: 5)]; + yield [new MatomoOptions(baseUrl: 'base_url', apiToken: 'api_token')]; + yield [new MatomoOptions(baseUrl: 'base_url', siteId: 5)]; + yield [new MatomoOptions(siteId: 5, apiToken: 'api_token')]; + } + + #[Test] + public function trackerIsCreated(): void + { + $tracker = $this->builder()->buildMatomoTracker(); + + self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line + self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout()); + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); + } + + private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + { + $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); + return new MatomoTrackerBuilder($options); + } +}