From b4386a3508b3d7b8512407c9c131c9464db3c17e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 4 Jun 2023 12:40:10 +0200 Subject: [PATCH 01/15] Add matomo container --- .gitignore | 1 + config/config.php | 66 +++++++++++++++++++++++++--------------------- docker-compose.yml | 21 +++++++++++++++ 3 files changed, 58 insertions(+), 30 deletions(-) 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/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..014764f38 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.14-apache + ports: + - "8003:80" + volumes: + # Matomo does not persist port in trusted hosts. This is needed to edit config afterwards + # 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" From b145d106b030fa2828f02660888719df38c9d695 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 8 Jun 2023 18:41:36 +0200 Subject: [PATCH 02/15] Add matomo env vars and config --- composer.json | 1 + config/autoload/matomo.global.php | 16 ++++++++++++++++ module/Core/src/Config/EnvVars.php | 14 +++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 config/autoload/matomo.global.php diff --git a/composer.json b/composer.json index 4f036a179..8dfeeb73d 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", diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php new file mode 100644 index 000000000..3fe1dd003 --- /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(), + 'token' => EnvVars::MATOMO_TOKEN->loadFromEnv(), + ], + +]; diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 1a9179288..d624c58ea 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_TOKEN = 'MATOMO_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'; From 7501eca71ed56cbe49c9e563567cd192cf7dd852 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Nov 2023 09:04:41 +0100 Subject: [PATCH 03/15] Update matomo container --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 014764f38..6a8fa2c50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -207,7 +207,7 @@ services: shlink_matomo: container_name: shlink_matomo - image: matomo:4.14-apache + image: matomo:4.15-apache ports: - "8003:80" volumes: From 0edb3e5c2c85a7df1f22276ce2b977154345fea1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 11 Nov 2023 20:12:39 +0100 Subject: [PATCH 04/15] Update to installer with support for matomo --- composer.json | 2 +- config/autoload/installer.global.php | 4 ++++ config/autoload/matomo.global.php | 2 +- docker-compose.yml | 2 +- module/Core/src/Config/EnvVars.php | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 8dfeeb73d..51e46b758 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#faf2582 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 index 3fe1dd003..a72d48a4d 100644 --- a/config/autoload/matomo.global.php +++ b/config/autoload/matomo.global.php @@ -10,7 +10,7 @@ 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), - 'token' => EnvVars::MATOMO_TOKEN->loadFromEnv(), + 'token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), ], ]; diff --git a/docker-compose.yml b/docker-compose.yml index 6a8fa2c50..5a0b6278f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -211,7 +211,7 @@ services: ports: - "8003:80" volumes: - # Matomo does not persist port in trusted hosts. This is needed to edit config afterwards + # Matomo does not persist port in trusted hosts. This is needed to edit config afterward # https://github.com/matomo-org/matomo/issues/9549 - ./data/infra/matomo:/var/www/html links: diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index d624c58ea..c966043fa 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -35,7 +35,7 @@ enum EnvVars: string case MATOMO_ENABLED = 'MATOMO_ENABLED'; case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; - case MATOMO_TOKEN = 'MATOMO_TOKEN'; + 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'; From 9dbd15bc0c020dcf1a0419601a2b38cf97a875dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Nov 2023 19:57:58 +0100 Subject: [PATCH 05/15] Add logic to send visits to a matomo instance --- config/autoload/matomo.global.php | 2 +- module/Core/config/dependencies.config.php | 5 + .../Core/config/event_dispatcher.config.php | 276 ++++++++++-------- .../Event/AbstractVisitEvent.php | 10 +- .../src/EventDispatcher/Event/UrlVisited.php | 14 - .../Core/src/EventDispatcher/LocateVisit.php | 4 +- .../Matomo/SendVisitToMatomo.php | 88 ++++++ module/Core/src/Matomo/MatomoOptions.php | 27 ++ .../Core/src/Matomo/MatomoTrackerBuilder.php | 39 +++ .../Matomo/MatomoTrackerBuilderInterface.php | 16 + module/Core/src/Visit/Entity/Visit.php | 5 + module/Core/src/Visit/VisitsTracker.php | 2 +- .../test/EventDispatcher/LocateVisitTest.php | 2 +- 13 files changed, 342 insertions(+), 148 deletions(-) create mode 100644 module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php create mode 100644 module/Core/src/Matomo/MatomoOptions.php create mode 100644 module/Core/src/Matomo/MatomoTrackerBuilder.php create mode 100644 module/Core/src/Matomo/MatomoTrackerBuilderInterface.php diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php index a72d48a4d..120ad2898 100644 --- a/config/autoload/matomo.global.php +++ b/config/autoload/matomo.global.php @@ -10,7 +10,7 @@ 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), - 'token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), + 'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), ], ]; 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..312e39172 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +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 +19,177 @@ 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, + ], ], - ], -]; + ]; +})(); 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/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..4e0bcb863 --- /dev/null +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -0,0 +1,88 @@ +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()); + + $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..d2423684a --- /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..655bbd0ba --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -0,0 +1,39 @@ +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); + // Token required to set the IP and location + $tracker->setTokenAuth($this->options->apiToken); + // We don't want to bulk send, as every request to Shlink will create a new tracker + $tracker->disableBulkTracking(); + // Ensure params are not sent in the URL, for security reasons + $tracker->setRequestMethodNonBulk('POST'); + + 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 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/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index b6f214951..21c3bf1d3 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'); From f88d57b2b646256f8185374379b84c437726cd36 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Nov 2023 20:02:35 +0100 Subject: [PATCH 06/15] Do not dispatch async job for matomo if disabled --- module/Core/config/event_dispatcher.config.php | 2 ++ .../src/EventDispatcher/Helper/EnabledListenerChecker.php | 3 +++ module/Core/src/Matomo/MatomoOptions.php | 8 ++++---- .../EventDispatcher/Helper/EnabledListenerCheckerTest.php | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 312e39172..1a81d8eda 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,7 @@ 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; @@ -188,6 +189,7 @@ MercureOptions::class, Options\WebhookOptions::class, GeoLite2Options::class, + MatomoOptions::class, ], ], 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/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php index d2423684a..235993211 100644 --- a/module/Core/src/Matomo/MatomoOptions.php +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -7,11 +7,11 @@ class MatomoOptions { public function __construct( - public readonly bool $enabled, - public readonly ?string $baseUrl, + public readonly bool $enabled = false, + public readonly ?string $baseUrl = null, /** @var numeric-string|int|null */ - private readonly string|int|null $siteId, - public readonly ?string $apiToken, + private readonly string|int|null $siteId = null, + public readonly ?string $apiToken = null, ) { } diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index de5017bdd..44ef500c1 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -17,6 +17,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; @@ -149,6 +150,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 +158,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), ); } } From a7ed14a1c9e8c98ed1409a4f3ea82e314438fcf9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 16 Nov 2023 09:24:52 +0100 Subject: [PATCH 07/15] Enhance EnableListenerCheckerTest with support for matomo listener --- .../Helper/EnabledListenerCheckerTest.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index 44ef500c1..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; @@ -27,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 @@ -39,6 +40,7 @@ public static function provideListeners(): iterable [NotifyNewShortUrlToRedis::class], [NotifyVisitToMercure::class], [NotifyNewShortUrlToMercure::class], + [SendVisitToMatomo::class], [NotifyVisitToWebHooks::class], [UpdateGeoLiteDb::class], ]; @@ -114,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, @@ -131,6 +145,7 @@ public static function provideConfiguredCheckers(): iterable mercureEnabled: true, webhooksEnabled: true, geoLiteEnabled: true, + matomoEnabled: true, ), [ NotifyVisitToRabbitMq::class => true, NotifyNewShortUrlToRabbitMq::class => true, @@ -138,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, From 5e6ebfa5a95f3ce4e595a18d7de3a88d93bddcc0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Nov 2023 09:32:07 +0100 Subject: [PATCH 08/15] Update shlink-event-dispatcher --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 51e46b758..8ccb6e140 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "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#c505a19 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", From e1f2dcc136e4469dc4f9c1dd984c69c6aad08833 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Nov 2023 23:31:23 +0100 Subject: [PATCH 09/15] Create MatomoTrackerBuilderTest --- .../test/Matomo/MatomoTrackerBuilderTest.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 module/Core/test/Matomo/MatomoTrackerBuilderTest.php diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php new file mode 100644 index 000000000..b7550bad6 --- /dev/null +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -0,0 +1,49 @@ +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); + self::assertEquals(5, $tracker->idSite); + } + + private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + { + $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); + return new MatomoTrackerBuilder($options); + } +} From bd5d3cb6fa3b41d9b28fbaac7949c3071a716216 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 20 Nov 2023 10:11:15 +0100 Subject: [PATCH 10/15] Create SendVisitToMatomoTest --- .../ShortUrl/Helper/ShortUrlStringifier.php | 3 + .../Matomo/SendVisitToMatomoTest.php | 189 ++++++++++++++++++ .../test/Matomo/MatomoTrackerBuilderTest.php | 4 +- 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 9d21cb58c..886a4d25d 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,6 +11,9 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface { + /** + * @param array{schema?: string, hostname?: string} $domainConfig + */ public function __construct(private readonly array $domainConfig, private readonly string $basePath = '') { } diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php new file mode 100644 index 000000000..154c7943e --- /dev/null +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -0,0 +1,189 @@ +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('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->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!'); + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willThrowException($e); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + $this->createMock(Visit::class), + ); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $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 index b7550bad6..5a38412a4 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -37,8 +37,8 @@ public function trackerIsCreated(): void { $tracker = $this->builder()->buildMatomoTracker(); - self::assertEquals('api_token', $tracker->token_auth); - self::assertEquals(5, $tracker->idSite); + self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line + self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line } private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder From c03eea789c76cf4d7c11b11137d1068cb386616b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 08:25:58 +0100 Subject: [PATCH 11/15] Fix LocateVisitTest --- module/Core/test/EventDispatcher/LocateVisitTest.php | 4 +++- .../test/EventDispatcher/Matomo/SendVisitToMatomoTest.php | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 21c3bf1d3..ddadde842 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -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 index 154c7943e..b76a1d314 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -160,13 +160,10 @@ public function logsErrorWhenTrackingFails(): void $visitId = '123'; $e = new Exception('Error!'); - $tracker = $this->createMock(MatomoTracker::class); - $tracker->expects($this->once())->method('setUrl')->willThrowException($e); - $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( $this->createMock(Visit::class), ); - $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $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}', From 316b88cea6c08e89cc6471703a241212c8b94700 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 08:34:37 +0100 Subject: [PATCH 12/15] Add 10 second timeout to matomo requests --- .../Core/src/Matomo/MatomoTrackerBuilder.php | 18 ++++++++++++------ .../test/Matomo/MatomoTrackerBuilderTest.php | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php index 655bbd0ba..393e04728 100644 --- a/module/Core/src/Matomo/MatomoTrackerBuilder.php +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -9,6 +9,8 @@ class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface { + public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds + public function __construct(private readonly MatomoOptions $options) { } @@ -27,12 +29,16 @@ public function buildMatomoTracker(): MatomoTracker // Create a new MatomoTracker on every request, because it infers request info during construction $tracker = new MatomoTracker($siteId, $this->options->baseUrl); - // Token required to set the IP and location - $tracker->setTokenAuth($this->options->apiToken); - // We don't want to bulk send, as every request to Shlink will create a new tracker - $tracker->disableBulkTracking(); - // Ensure params are not sent in the URL, for security reasons - $tracker->setRequestMethodNonBulk('POST'); + $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 + ->disableBulkTracking(); return $tracker; } diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index 5a38412a4..5a4e6ab0f 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -39,6 +39,8 @@ public function trackerIsCreated(): void 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 From e783bdc456e8407f4d25c1f4330467d0666f3546 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 10:01:27 +0100 Subject: [PATCH 13/15] Set referrer when sending visits to Matomo --- .../src/EventDispatcher/Matomo/SendVisitToMatomo.php | 3 ++- module/Core/src/Matomo/MatomoTrackerBuilder.php | 9 ++++++--- module/Core/src/Visit/Entity/Visit.php | 5 +++++ .../EventDispatcher/Matomo/SendVisitToMatomoTest.php | 2 ++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index 4e0bcb863..ad9660cba 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -47,7 +47,8 @@ public function __invoke(VisitLocated $visitLocated): void $tracker ->setUrl($this->resolveUrlToTrack($visit)) ->setCustomTrackingParameter('type', $visit->type()->value) - ->setUserAgent($visit->userAgent()); + ->setUserAgent($visit->userAgent()) + ->setUrlReferrer($visit->referer()); $location = $visit->getVisitLocation(); if ($location !== null) { diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php index 393e04728..4bad67990 100644 --- a/module/Core/src/Matomo/MatomoTrackerBuilder.php +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -36,9 +36,12 @@ public function buildMatomoTracker(): MatomoTracker ->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 - ->disableBulkTracking(); + ->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/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index a735a3fb2..255a55f44 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -193,6 +193,11 @@ public function userAgent(): string return $this->userAgent; } + public function referer(): string + { + return $this->referer; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index b76a1d314..94c666232 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -71,6 +71,7 @@ public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress $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()) { @@ -125,6 +126,7 @@ public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): vo $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'); From 5e6e386c5a877dac79563ec535bab001eacaba6c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Nov 2023 18:30:03 +0100 Subject: [PATCH 14/15] Add matomo dev config --- config/autoload/matomo.local.php.dist | 26 ++++++++++++++++++++++++++ docker-compose.yml | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 config/autoload/matomo.local.php.dist 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/docker-compose.yml b/docker-compose.yml index 5a0b6278f..e44ca82b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -211,7 +211,7 @@ services: ports: - "8003:80" volumes: - # Matomo does not persist port in trusted hosts. This is needed to edit config afterward + # 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: From bd5d3f6897f90754a36e8d6d546dc4bdd0d990ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Nov 2023 18:51:47 +0100 Subject: [PATCH 15/15] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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.